diff --git a/.env.example b/.env.example index af593a02a5..d31e89dd21 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app.json b/app.json index 33c450b943..17d25a93c9 100644 --- a/app.json +++ b/app.json @@ -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 @@ } } } -} +} \ No newline at end of file diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 983579d0c1..769aa0986d 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -41,6 +41,7 @@ SENTRY_DEBUG= # FEATURE TOGGLE COMMENT_FEATURE_ENABLE= +ENABLE_MULTIPLAYER_EDITING=true #SSO SSO_DISABLE_SIGNUP= diff --git a/docs/docs/password-login/_category_.json b/docs/docs/password-login/_category_.json new file mode 100644 index 0000000000..3b2732e18c --- /dev/null +++ b/docs/docs/password-login/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Password Login", + "position": 10, + "collapsed": true +} \ No newline at end of file diff --git a/docs/docs/password-login/password-login.md b/docs/docs/password-login/password-login.md new file mode 100644 index 0000000000..620ed24ae7 --- /dev/null +++ b/docs/docs/password-login/password-login.md @@ -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 + +
+ +![ToolJet - SSO configs](/img/password-login/organization-menu.png) + +
+ +Select `Password Login`. You can enable/disable it + +
+ +![ToolJet - Password Login configs](/img/password-login/password-login.png) + +
diff --git a/docs/docs/setup/env-vars.md b/docs/docs/setup/env-vars.md index a332dfda78..75fc391d5f 100644 --- a/docs/docs/setup/env-vars.md +++ b/docs/docs/setup/env-vars.md @@ -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. diff --git a/docs/docs/sso/general-settings.md b/docs/docs/sso/general-settings.md new file mode 100644 index 0000000000..87c8bc8d87 --- /dev/null +++ b/docs/docs/sso/general-settings.md @@ -0,0 +1,30 @@ +--- +sidebar_position: 6 +sidebar_label: General Settings +--- + +# Single Sign-On General Settings + +Select `Manage SSO` from organization options + +
+ +![ToolJet - SSO configs](/img/password-login/organization-menu.png) + +
+ +Select `General Settings` + +
+ +![ToolJet - SSO configs](/img/sso/general/general-settings.png) + +
+ +## 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. diff --git a/docs/docs/sso/github.md b/docs/docs/sso/github.md index d6f201bc88..1d5aa22e2f 100644 --- a/docs/docs/sso/github.md +++ b/docs/docs/sso/github.md @@ -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 `/sso/git` +Select `Manage SSO` from organization options + +
+ +![ToolJet - SSO configs](/img/password-login/organization-menu.png) + +
+ +Select `Git`, Git login will be disabled by default + +
+ +![ToolJet - SSO configs](/img/sso/git/manage-sso-1.png) + +
+ +Enable Git. You can see `Redirect URL` generated + +
+ +![ToolJet - SSO configs](/img/sso/git/manage-sso-2.png) + +
+ +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.
@@ -29,14 +53,6 @@ title: GitHub
-- 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. -::: - -
-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. diff --git a/docs/docs/sso/google.md b/docs/docs/sso/google.md index e8588f3b44..b7a1988265 100644 --- a/docs/docs/sso/google.md +++ b/docs/docs/sso/google.md @@ -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 + +
+ +![ToolJet - SSO configs](/img/password-login/organization-menu.png) + +
+ +Select `Google`, Google login will be disabled by default + +
+ +![ToolJet - SSO configs](/img/sso/google/manage-sso-1.png) + +
+ +Enable Google. You can see `Redirect URL` generated + +
+ +![ToolJet - SSO configs](/img/sso/google/manage-sso-2.png) + +
+ +Go to [Google cloud console](https://console.cloud.google.com/) and create a project.
@@ -48,20 +72,14 @@ user who is signing in
-- 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 +
-### 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. -::: +
-
-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. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a82ef2051f..e86da458ad 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -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, }, ], ], diff --git a/docs/static/img/password-login/organization-menu.png b/docs/static/img/password-login/organization-menu.png new file mode 100644 index 0000000000..b2bb65e8ba Binary files /dev/null and b/docs/static/img/password-login/organization-menu.png differ diff --git a/docs/static/img/password-login/password-login.png b/docs/static/img/password-login/password-login.png new file mode 100644 index 0000000000..8ffc3d50a1 Binary files /dev/null and b/docs/static/img/password-login/password-login.png differ diff --git a/docs/static/img/sso/general/general-settings.png b/docs/static/img/sso/general/general-settings.png new file mode 100644 index 0000000000..88b34f1abe Binary files /dev/null and b/docs/static/img/sso/general/general-settings.png differ diff --git a/docs/static/img/sso/git/manage-sso-1.png b/docs/static/img/sso/git/manage-sso-1.png new file mode 100644 index 0000000000..cab766ced4 Binary files /dev/null and b/docs/static/img/sso/git/manage-sso-1.png differ diff --git a/docs/static/img/sso/git/manage-sso-2.png b/docs/static/img/sso/git/manage-sso-2.png new file mode 100644 index 0000000000..24c6c7698b Binary files /dev/null and b/docs/static/img/sso/git/manage-sso-2.png differ diff --git a/docs/static/img/sso/google/authorized-redirect-urls.png b/docs/static/img/sso/google/authorized-redirect-urls.png new file mode 100644 index 0000000000..e185607d92 Binary files /dev/null and b/docs/static/img/sso/google/authorized-redirect-urls.png differ diff --git a/docs/static/img/sso/google/manage-sso-1.png b/docs/static/img/sso/google/manage-sso-1.png new file mode 100644 index 0000000000..e15d1380e5 Binary files /dev/null and b/docs/static/img/sso/google/manage-sso-1.png differ diff --git a/docs/static/img/sso/google/manage-sso-2.png b/docs/static/img/sso/google/manage-sso-2.png new file mode 100644 index 0000000000..5dcbb0423b Binary files /dev/null and b/docs/static/img/sso/google/manage-sso-2.png differ diff --git a/frontend/assets/images/icons/editor/left-sidebar/components.svg b/frontend/assets/images/icons/editor/left-sidebar/components.svg new file mode 100644 index 0000000000..ff16a6ac61 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/components.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/assets/images/icons/editor/left-sidebar/globals.svg b/frontend/assets/images/icons/editor/left-sidebar/globals.svg new file mode 100644 index 0000000000..ca1f23d701 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/globals.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/images/icons/editor/left-sidebar/pinnedoff.svg b/frontend/assets/images/icons/editor/left-sidebar/pinnedoff.svg new file mode 100644 index 0000000000..e70c7a61ee --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/pinnedoff.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/assets/images/icons/editor/left-sidebar/queries.svg b/frontend/assets/images/icons/editor/left-sidebar/queries.svg new file mode 100644 index 0000000000..caed52b362 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/queries.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/assets/images/icons/editor/left-sidebar/variables.svg b/frontend/assets/images/icons/editor/left-sidebar/variables.svg new file mode 100644 index 0000000000..a08b024ac0 --- /dev/null +++ b/frontend/assets/images/icons/editor/left-sidebar/variables.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/assets/images/icons/widgets/verticaldivider.svg b/frontend/assets/images/icons/widgets/verticaldivider.svg new file mode 100644 index 0000000000..c57d739728 --- /dev/null +++ b/frontend/assets/images/icons/widgets/verticaldivider.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/assets/images/sso-buttons/unknown.svg b/frontend/assets/images/sso-buttons/unknown.svg index d4bf3f64cf..a378f1f692 100644 --- a/frontend/assets/images/sso-buttons/unknown.svg +++ b/frontend/assets/images/sso-buttons/unknown.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/frontend/ee/components/LoginPage/GitSSOLoginButton.jsx b/frontend/ee/components/LoginPage/GitSSOLoginButton.jsx index 2bd0c8a976..08af60c678 100644 --- a/frontend/ee/components/LoginPage/GitSSOLoginButton.jsx +++ b/frontend/ee/components/LoginPage/GitSSOLoginButton.jsx @@ -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 (
diff --git a/frontend/ee/components/LoginPage/GoogleSSOLoginButton.jsx b/frontend/ee/components/LoginPage/GoogleSSOLoginButton.jsx index 146f423124..3de6f1becb 100644 --- a/frontend/ee/components/LoginPage/GoogleSSOLoginButton.jsx +++ b/frontend/ee/components/LoginPage/GoogleSSOLoginButton.jsx @@ -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 (
(
+
+
+

+ OR +

+
+ + )} +

Set up your account

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+

+ By clicking the button below, you agree to our{' '} + Terms and Conditions. +

+ +
+
+ +
+ + ); + } +} + +export { OrganizationInvitationPage }; diff --git a/frontend/src/ConfirmationPage/index.js b/frontend/src/ConfirmationPage/index.js index 095a93bcd2..00f8d943dc 100644 --- a/frontend/src/ConfirmationPage/index.js +++ b/frontend/src/ConfirmationPage/index.js @@ -1 +1,2 @@ export * from './ConfirmationPage'; +export * from './OrganizationInvitationPage'; diff --git a/frontend/src/Editor/Box.jsx b/frontend/src/Editor/Box.jsx index 0c565c5f63..b2b7a2ab72 100644 --- a/frontend/src/Editor/Box.jsx +++ b/frontend/src/Editor/Box.jsx @@ -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({ diff --git a/frontend/src/Editor/Components/DropDown.jsx b/frontend/src/Editor/Components/DropDown.jsx index 192234f8fa..8c8e6e38cd 100644 --- a/frontend/src/Editor/Components/DropDown.jsx +++ b/frontend/src/Editor/Components/DropDown.jsx @@ -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]); diff --git a/frontend/src/Editor/Components/components.js b/frontend/src/Editor/Components/components.js index 944f920560..2a30fb0560 100644 --- a/frontend/src/Editor/Components/components.js +++ b/frontend/src/Editor/Components/components.js @@ -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' }, + }, + }, + }, ]; diff --git a/frontend/src/Editor/Components/verticalDivider.jsx b/frontend/src/Editor/Components/verticalDivider.jsx new file mode 100644 index 0000000000..ce618225a1 --- /dev/null +++ b/frontend/src/Editor/Components/verticalDivider.jsx @@ -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 ( +
+
+
+
+ ); +}; diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index 28d72cc313..c2b89cf93b 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -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 { )} - + {config.ENABLE_MULTIPLAYER_EDITING && ( + + )} {editingVersion && ( )} - -
{this.renderLayoutIcon(currentLayout === 'desktop')}
-
-
+
+
+ + + undo + + + undo + + + + + redo + + + +
+
+ {this.renderLayoutIcon(currentLayout === 'desktop')} +
- {currentSidebarTab === 1 && (
{selectedComponent && diff --git a/frontend/src/Editor/Icons/desktop-selected.svg b/frontend/src/Editor/Icons/desktop-selected.svg index d912ec1f43..2f1777de50 100644 --- a/frontend/src/Editor/Icons/desktop-selected.svg +++ b/frontend/src/Editor/Icons/desktop-selected.svg @@ -5,15 +5,12 @@ fill="none" xmlns="http://www.w3.org/2000/svg" > - - - - - - { +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: () => }; + } + const Icon = allSvgs[value.kind]; + return { iconName: key, jsx: () => }; + }); + + 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 ( <> {
{ updateState={updatePopoverPinnedState} />
- {jsonData.map((data) => ( - - ))} +
diff --git a/frontend/src/Editor/LeftSidebar/SidebarPinnedButton.jsx b/frontend/src/Editor/LeftSidebar/SidebarPinnedButton.jsx index dfae481a29..7d08c61ac5 100644 --- a/frontend/src/Editor/LeftSidebar/SidebarPinnedButton.jsx +++ b/frontend/src/Editor/LeftSidebar/SidebarPinnedButton.jsx @@ -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 ( @@ -13,7 +14,12 @@ export const SidebarPinnedButton = ({ state, component, updateState, darkMode }) }`} onClick={updateState} > - +
); diff --git a/frontend/src/Editor/LeftSidebar/index.js b/frontend/src/Editor/LeftSidebar/index.js index 0acc9af803..f5c9279b3d 100644 --- a/frontend/src/Editor/LeftSidebar/index.js +++ b/frontend/src/Editor/LeftSidebar/index.js @@ -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 (
- + +
handleSearchQueryChange(e)} diff --git a/frontend/src/LoginPage/LoginPage.jsx b/frontend/src/LoginPage/LoginPage.jsx index 0373be6dc7..f422826887 100644 --- a/frontend/src/LoginPage/LoginPage.jsx +++ b/frontend/src/LoginPage/LoginPage.jsx @@ -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 ( +
+
+
+
+
+
+
+
+
+
+
+ ); + }; + render() { - const { isLoading } = this.state; - const passwordLoginDisabled = window.public_config?.DISABLE_PASSWORD_LOGIN === 'true'; + const { isLoading, configs, isGettingConfigs } = this.state; return (
-
- {!passwordLoginDisabled && ( -
-

- Login to your account -

-
- - -
-
- -
+ {isGettingConfigs ? ( + this.showLoading() + ) : ( +
+ {!configs &&
No login methods enabled for this organization
} + {configs?.form?.enabled && ( +
+

+ Login to {this.single_organization ? 'your account' : configs?.name || 'your account'} +

+
+ - +
+
+ +
+ + +
+
+
+ +
-
- + {configs?.form?.enabled && ( + + )} + {this.state.configs?.google?.enabled && ( + - -
+ )} + {this.state.configs?.git?.enabled && }
- )} -
- {!passwordLoginDisabled && ( - - )} - {window.public_config?.SSO_GOOGLE_OAUTH2_CLIENT_ID && ( - - )} - {window.public_config?.SSO_GIT_OAUTH2_CLIENT_ID && }
-
+ )} - {!passwordLoginDisabled && ( -
- Don't have account yet?   - - Sign up - + {!this.props.match.params.organisationId && + !this.single_organization && + configs?.form?.enabled && + configs?.form?.enable_sign_up && ( +
+ Don't have account yet?   + + Sign up + +
+ )} + {authenticationService?.currentUserValue?.organization && ( + )}
diff --git a/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx b/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx index c45b567da4..7e66af89fb 100644 --- a/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx +++ b/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx @@ -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', { diff --git a/frontend/src/ManageSSO/Form.jsx b/frontend/src/ManageSSO/Form.jsx new file mode 100644 index 0000000000..d61c078995 --- /dev/null +++ b/frontend/src/ManageSSO/Form.jsx @@ -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 ( +
+
+
+
+ Password Login + {enabled ? 'Enabled' : 'Disabled'} +
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/ManageSSO/GeneralSettings.jsx b/frontend/src/ManageSSO/GeneralSettings.jsx new file mode 100644 index 0000000000..d02817ccef --- /dev/null +++ b/frontend/src/ManageSSO/GeneralSettings.jsx @@ -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 ( +
+
+
General Settings
+
+
+
+
+ +
+
New account will be created for user's first time sso sign in
+
+
+
+ +
+ setDomain(e.target.value)} + /> +
+
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/ManageSSO/Git.jsx b/frontend/src/ManageSSO/Git.jsx new file mode 100644 index 0000000000..bf575826d8 --- /dev/null +++ b/frontend/src/ManageSSO/Git.jsx @@ -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 ( +
+
+
+
+ Git + {enabled ? 'Enabled' : 'Disabled'} +
+
+ +
+
+
+
+
+
+ +
+ setClientId(e.target.value)} + /> +
+
+
+ +
+ setClientSecret(e.target.value)} + /> +
+
+ {configId && ( +
+ +
{`${window.location.protocol}//${window.location.host}/sso/git/${configId}`}
+
+ )} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/ManageSSO/Google.jsx b/frontend/src/ManageSSO/Google.jsx new file mode 100644 index 0000000000..5c49075383 --- /dev/null +++ b/frontend/src/ManageSSO/Google.jsx @@ -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 ( +
+
+
+
+ Google + {enabled ? 'Enabled' : 'Disabled'} +
+
+ +
+
+
+
+
+
+ +
+ setClientId(e.target.value)} + /> +
+
+ {configId && ( +
+ +
{`${window.location.protocol}//${window.location.host}/sso/google/${configId}`}
+
+ )} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/ManageSSO/Loader.jsx b/frontend/src/ManageSSO/Loader.jsx new file mode 100644 index 0000000000..da9fb268c5 --- /dev/null +++ b/frontend/src/ManageSSO/Loader.jsx @@ -0,0 +1,19 @@ +import React from 'react'; + +export function Loader() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/ManageSSO/ManageSSO.jsx b/frontend/src/ManageSSO/ManageSSO.jsx new file mode 100644 index 0000000000..c0359ddd8f --- /dev/null +++ b/frontend/src/ManageSSO/ManageSSO.jsx @@ -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 ; + case 'google': + return obj.sso === 'google')} />; + case 'git': + return obj.sso === 'git')} />; + case 'form': + return
obj.sso === 'form')} />; + default: + return ; + } + }; + + 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 ( +
+
+ + +
+
+
+
+
+
+

Manage SSO

+
+
+
+
+ +
+
+
+
+
+ {isLoading ? ( +
+
+
+
+
+
+
+
+
+
+
+ ) : ( + + )} +
+
+
{showPage()}
+
+
+
+
+
+ ); +} diff --git a/frontend/src/ManageSSO/index.js b/frontend/src/ManageSSO/index.js new file mode 100644 index 0000000000..2be6982644 --- /dev/null +++ b/frontend/src/ManageSSO/index.js @@ -0,0 +1 @@ +export * from './ManageSSO'; diff --git a/frontend/src/Oauth/Authorize.jsx b/frontend/src/Oauth/Authorize.jsx index 8bd259ef13..0f2de7e0ca 100644 --- a/frontend/src/Oauth/Authorize.jsx +++ b/frontend/src/Oauth/Authorize.jsx @@ -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() { )} diff --git a/frontend/src/Oauth/Configs/Config.json b/frontend/src/Oauth/Configs/Config.json index 11b1edaab4..e0bfff3195 100644 --- a/frontend/src/Oauth/Configs/Config.json +++ b/frontend/src/Oauth/Configs/Config.json @@ -1,8 +1,16 @@ { "git": { "name": "Github", + "responseType": "query", "params": { "token": "code" } + }, + "google": { + "name": "Google", + "responseType": "hash", + "params": { + "token": "id_token" + } } } \ No newline at end of file diff --git a/frontend/src/_components/CopyToClipboard/CopyToClipboard.jsx b/frontend/src/_components/CopyToClipboard/CopyToClipboard.jsx new file mode 100644 index 0000000000..19ec92ff60 --- /dev/null +++ b/frontend/src/_components/CopyToClipboard/CopyToClipboard.jsx @@ -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
Copied
; + } + + return ( + + { + setCopied(true); + toast.success(message, { position: 'top-center' }); + }} + > + + + + + + ); +}; diff --git a/frontend/src/_components/CopyToClipboard/index.js b/frontend/src/_components/CopyToClipboard/index.js new file mode 100644 index 0000000000..2d360006d4 --- /dev/null +++ b/frontend/src/_components/CopyToClipboard/index.js @@ -0,0 +1,3 @@ +import { CopyToClipboardComponent } from './CopyToClipboard'; + +export default CopyToClipboardComponent; diff --git a/frontend/src/_components/Header.jsx b/frontend/src/_components/Header.jsx index e6b5956571..ce613cb568 100644 --- a/frontend/src/_components/Header.jsx +++ b/frontend/src/_components/Header.jsx @@ -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 }) {
+
+ +
- {admin && ( - - Manage Users - - )} - {admin && ( - - Manage Groups - - )} Profile diff --git a/frontend/src/_components/Menu.jsx b/frontend/src/_components/Menu.jsx new file mode 100644 index 0000000000..6f4dde36b6 --- /dev/null +++ b/frontend/src/_components/Menu.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +export function Menu({ onChange, items, selected }) { + return ( +
+
    + {items && + items.map((item) => ( +
  • onChange(item.id)} className={selected === item.id ? 'active' : ''}> + {item.label} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/_components/Organization.jsx b/frontend/src/_components/Organization.jsx new file mode 100644 index 0000000000..9b65d3f4a9 --- /dev/null +++ b/frontend/src/_components/Organization.jsx @@ -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 ( +
switchOrganization(org.id)} + className="dropdown-item org-list-item" + > +
+ {getAvatar(org.name)} +
+
+
{org.name}
+
+
+ {organization_id === org.id && ( +
+ + + + +
+ )} +
+
+ ); + }) + ); + }; + + const searchOrganizations = (text) => { + setSearchText(text); + }; + + const getListOrganizations = () => { + return ( +
+
+
+
setIsListOrganizations(false)}> + + + + +
+
setIsListOrganizations(false)}> + Back +
+
+
+ +
+
+
+ {getOrgStatus === 'success' ? ( + listOrganization() + ) : ( + + )} +
+
+ ); + }; + + const getOrganizationMenu = () => { + return ( +
+
+
+
+ {getAvatar(organization)} +
+
+
+ {organization} +
+ {admin && ( +
+ Edit +
+ )} +
+ {!isSingleOrganization && ( +
+
setIsListOrganizations(true)}> + + + + +
+
+ )} +
+
+ {!isSingleOrganization && ( +
+
Add Organizations
+
+ )} + {admin && ( + <> +
+ + Manage Users + + + Manage Groups + + + Manage SSO + + + )} +
+ ); + }; + + return ( +
+
setIsListOrganizations(false)}> + +
{organization}
+
+ {(!isSingleOrganization || admin) && ( +
+ {isListOrganizations ? getListOrganizations() : getOrganizationMenu()} +
+ )} +
+ setShowCreateOrg(false)} title="Create organization"> +
+
+ setNewOrgName(e.target.value)} + className="form-control" + placeholder="organization name" + disabled={isCreating} + maxLength={25} + /> +
+
+
+
+ + +
+
+
+ setShowEditOrg(false)} title="Edit organization"> +
+
+ setNewOrgName(e.target.value)} + className="form-control" + placeholder="organization name" + disabled={isCreating} + value={newOrgName} + maxLength={25} + /> +
+
+
+
+ + +
+
+
+
+ ); +}; diff --git a/frontend/src/_components/SearchBox.jsx b/frontend/src/_components/SearchBox.jsx index e639e504b8..afc279a739 100644 --- a/frontend/src/_components/SearchBox.jsx +++ b/frontend/src/_components/SearchBox.jsx @@ -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 }) { )} { // 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); +} diff --git a/frontend/src/_services/comments.service.js b/frontend/src/_services/comments.service.js index b5efc14e82..94f31d94a2 100644 --- a/frontend/src/_services/comments.service.js +++ b/frontend/src/_services/comments.service.js @@ -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) { diff --git a/frontend/src/_services/organization.service.js b/frontend/src/_services/organization.service.js index 5ea3820ba8..9fdb5bf27a 100644 --- a/frontend/src/_services/organization.service.js +++ b/frontend/src/_services/organization.service.js @@ -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); +} diff --git a/frontend/src/_services/user.service.js b/frontend/src/_services/user.service.js index b896f00c74..60006945cd 100644 --- a/frontend/src/_services/user.service.js +++ b/frontend/src/_services/user.service.js @@ -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) }; diff --git a/frontend/src/_styles/colors.scss b/frontend/src/_styles/colors.scss index 6da3674ba5..71e4b7dd36 100644 --- a/frontend/src/_styles/colors.scss +++ b/frontend/src/_styles/colors.scss @@ -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; diff --git a/frontend/src/_styles/theme.scss b/frontend/src/_styles/theme.scss index e9e71b110e..c0e2b1e233 100644 --- a/frontend/src/_styles/theme.scss +++ b/frontend/src/_styles/theme.scss @@ -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; +} \ No newline at end of file diff --git a/frontend/src/_ui/JSONTreeViewer/JSONNode.jsx b/frontend/src/_ui/JSONTreeViewer/JSONNode.jsx new file mode 100644 index 0000000000..97f348e6cd --- /dev/null +++ b/frontend/src/_ui/JSONTreeViewer/JSONNode.jsx @@ -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 = ; + $NODEType = ; + break; + + case 'Object': + $VALUE = ; + $NODEType = ( + + + {`${numberOfEntries} ${numberOfEntries > 1 ? 'entries' : 'entry'}`}{' '} + + + ); + break; + + case 'Array': + $VALUE = ; + $NODEType = ( + + + {`${numberOfEntries} ${numberOfEntries > 1 ? 'items' : 'item'}`}{' '} + + + ); + + break; + + default: + $VALUE = {String(data)}; + $NODEType = typeofCurrentNode; + } + + let $key = ( + 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)} + + ); + + 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 ( + +
+ {actions?.map((action, index) => ( + { + action.dispatchAction(data, currentNode); + updateParentState(); + }} + > + {action.name} + + ))} +
+
+ ); + } + + 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 ( + + dispatchAction(data, currentNode)} + > + + + + ); + } + }); + }; + + return ( +
+ {enableCopyToClipboard && ( + + )} + {renderOptions()} + + {moreActions.actions?.length > 0 && ( + + + + + + + + + + + + + )} +
+ ); + }; + + return ( +
+
+ +
+ +
+
updateHoveredNode(currentNode, currentNodePath)} + onMouseLeave={() => updateHoveredNode(null)} + className={cx('d-flex', { + 'group-object-container': shouldDisplayIntendedBlock, + 'mx-2': typeofCurrentNode !== 'Object' && typeofCurrentNode !== 'Array', + })} + > + {$NODEIcon &&
{$NODEIcon}
} + {$key} {$NODEType} + {!toExpandNode && !expandable && !toRenderSelector ? $VALUE : null} +
{showHiddenOptionsForNode && renderHiddenOptionsForNode()}
+
+ {toRenderSelector && (toExpandNode && !expandable ? null : $VALUE)} +
+
+ ); +}; + +const DisplayNodeLabel = ({ type = '', children }) => { + if (type === 'Null' || type === 'Undefined') { + return null; + } + return ( + <> + {type} + {children} + + ); +}; + +JSONNode.DisplayNodeLabel = DisplayNodeLabel; diff --git a/frontend/src/_ui/JSONTreeViewer/JSONNodeArray.jsx b/frontend/src/_ui/JSONTreeViewer/JSONNodeArray.jsx new file mode 100644 index 0000000000..396e8c7b63 --- /dev/null +++ b/frontend/src/_ui/JSONTreeViewer/JSONNodeArray.jsx @@ -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 ; + }); +}; + +export default JSONTreeArrayNode; diff --git a/frontend/src/_ui/JSONTreeViewer/JSONNodeIndicator.jsx b/frontend/src/_ui/JSONTreeViewer/JSONNodeIndicator.jsx new file mode 100644 index 0000000000..b4cebc69c7 --- /dev/null +++ b/frontend/src/_ui/JSONTreeViewer/JSONNodeIndicator.jsx @@ -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 = () => ( + + + + ); + + if (!toShowNodeIndicator && (typeofCurrentNode !== 'Object' || typeofCurrentNode !== 'Array')) return null; + + return ( + + + {renderCustomIndicator ? renderCustomIndicator() : renderDefaultIndicator()} + + + ); +}; + +export default JSONTreeNodeIndicator; diff --git a/frontend/src/_ui/JSONTreeViewer/JSONNodeObject.jsx b/frontend/src/_ui/JSONTreeViewer/JSONNodeObject.jsx new file mode 100644 index 0000000000..cf466db6db --- /dev/null +++ b/frontend/src/_ui/JSONTreeViewer/JSONNodeObject.jsx @@ -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 ; + }); +}; + +export default JSONTreeObjectNode; diff --git a/frontend/src/_ui/JSONTreeViewer/JSONNodeValue.jsx b/frontend/src/_ui/JSONTreeViewer/JSONNodeValue.jsx new file mode 100644 index 0000000000..1596a50d83 --- /dev/null +++ b/frontend/src/_ui/JSONTreeViewer/JSONNodeValue.jsx @@ -0,0 +1,31 @@ +import React from 'react'; + +const JSONTreeValueNode = ({ data, type }) => { + if (type === 'Function') { + const functionString = `${data.toString().split('{')[0].trim()}{...}`; + return ( + + + {functionString} + + + ); + } + + const value = type === 'String' ? `"${data}"` : String(data); + const clsForUndefinedOrNull = (type === 'Undefined' || type === 'Null') && 'badge badge-secondary'; + return ( + + {value} + + ); +}; + +export default JSONTreeValueNode; diff --git a/frontend/src/_ui/JSONTreeViewer/JSONTreeViewer.jsx b/frontend/src/_ui/JSONTreeViewer/JSONTreeViewer.jsx new file mode 100644 index 0000000000..82186dadc4 --- /dev/null +++ b/frontend/src/_ui/JSONTreeViewer/JSONTreeViewer.jsx @@ -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 ( + + ); + } + 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 ( +
+ + + +
+ ); + } +} diff --git a/frontend/src/_ui/JSONTreeViewer/index.js b/frontend/src/_ui/JSONTreeViewer/index.js new file mode 100644 index 0000000000..b4a8432a22 --- /dev/null +++ b/frontend/src/_ui/JSONTreeViewer/index.js @@ -0,0 +1,3 @@ +import { JSONTreeViewer } from './JSONTreeViewer'; + +export default JSONTreeViewer; diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index fa4e2dbf1a..b5b9c8f36f 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -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, }), }, }; diff --git a/package.json b/package.json index 475fb9eedd..7762cd75a9 100644 --- a/package.json +++ b/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/plugins/package-lock.json b/plugins/package-lock.json index f37acb9fea..1571570ce3 100644 --- a/plugins/package-lock.json +++ b/plugins/package-lock.json @@ -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", diff --git a/plugins/packages/elasticsearch/lib/index.ts b/plugins/packages/elasticsearch/lib/index.ts index 6afa7aeadf..838990c0b9 100644 --- a/plugins/packages/elasticsearch/lib/index.ts +++ b/plugins/packages/elasticsearch/lib/index.ts @@ -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 { diff --git a/server/ee/controllers/oauth.controller.ts b/server/ee/controllers/oauth.controller.ts index 0f2d7721c3..f92015d2aa 100644 --- a/server/ee/controllers/oauth.controller.ts +++ b/server/ee/controllers/oauth.controller.ts @@ -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; } } diff --git a/server/ee/services/oauth/git_oauth.service.ts b/server/ee/services/oauth/git_oauth.service.ts index c147279fa8..4b21fc6a34 100644 --- a/server/ee/services/oauth/git_oauth.service.ts +++ b/server/ee/services/oauth/git_oauth.service.ts @@ -5,35 +5,47 @@ import UserResponse from './models/user_response'; @Injectable() export class GitOAuthService { - constructor(private readonly configService: ConfigService) { - this.clientId = this.configService.get('SSO_GIT_OAUTH2_CLIENT_ID'); - this.clientSecret = this.configService.get('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 { 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 { + 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 { 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); } } diff --git a/server/ee/services/oauth/google_oauth.service.ts b/server/ee/services/oauth/google_oauth.service.ts index f00179d19e..d95bbafcf0 100644 --- a/server/ee/services/oauth/google_oauth.service.ts +++ b/server/ee/services/oauth/google_oauth.service.ts @@ -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('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 { - const ticket = await this.client.verifyIdToken({ + async signIn(token: string, configs: any): Promise { + 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); diff --git a/server/ee/services/oauth/models/user_response.ts b/server/ee/services/oauth/models/user_response.ts index b03c701d2b..3d590e0ebf 100644 --- a/server/ee/services/oauth/models/user_response.ts +++ b/server/ee/services/oauth/models/user_response.ts @@ -3,6 +3,5 @@ export default interface UserResponse { firstName?: string; lastName?: string; email: string; - domain?: string; sso: string; } diff --git a/server/ee/services/oauth/oauth.service.ts b/server/ee/services/oauth/oauth.service.ts index 5bc321f83e..969da0625b 100644 --- a/server/ee/services/oauth/oauth.service.ts +++ b/server/ee/services/oauth/oauth.service.ts @@ -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('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 { - const organization = await this.organizationService.findFirst(); + async #findOrCreateUser({ firstName, lastName, email }: UserResponse, organization: Organization): Promise { 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 { - const user = await this.usersService.findByEmail(email); + async #findAndActivateUser(email: string, organizationId: string): Promise { + 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 { - const JWTPayload: JWTPayload = { username: user.id, sub: user.email, ssoId: user.ssoId, sso: user.sso }; + async #generateLoginResultPayload(user: User, organization: Organization): Promise { + 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 { - const ssoSignUpDisabled = - this.configService.get('SSO_DISABLE_SIGNUP') && - this.configService.get('SSO_DISABLE_SIGNUP') === 'true'; + async signIn(ssoResponse: SSOResponse, configId: string): Promise { + 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; } diff --git a/server/migrations/1632468258787-PopulateUserGroupsFromOrganizationRoles.ts b/server/migrations/1632468258787-PopulateUserGroupsFromOrganizationRoles.ts index 2da0bdb51b..16f875d67e 100644 --- a/server/migrations/1632468258787-PopulateUserGroupsFromOrganizationRoles.ts +++ b/server/migrations/1632468258787-PopulateUserGroupsFromOrganizationRoles.ts @@ -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); diff --git a/server/migrations/1639734070615-BackfillDataSourcesAndQueriesForAppVersions.ts b/server/migrations/1639734070615-BackfillDataSourcesAndQueriesForAppVersions.ts index 8bb90026fa..b2fc7907d3 100644 --- a/server/migrations/1639734070615-BackfillDataSourcesAndQueriesForAppVersions.ts +++ b/server/migrations/1639734070615-BackfillDataSourcesAndQueriesForAppVersions.ts @@ -13,7 +13,9 @@ import { cloneDeep } from 'lodash'; export class BackfillDataSourcesAndQueriesForAppVersions1639734070615 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { 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); diff --git a/server/migrations/1645864719155-MultiOrganization.ts b/server/migrations/1645864719155-MultiOrganization.ts new file mode 100644 index 0000000000..cfe1c38236 --- /dev/null +++ b/server/migrations/1645864719155-MultiOrganization.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class MultiOrganization1645864719155 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 {} +} diff --git a/server/migrations/1646823984673-OrganizationConfigs.ts b/server/migrations/1646823984673-OrganizationConfigs.ts new file mode 100644 index 0000000000..abd160b98b --- /dev/null +++ b/server/migrations/1646823984673-OrganizationConfigs.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +export class OrganizationConfigs1646823984673 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 {} +} diff --git a/server/migrations/1650455299630-OrganizationEnableSignup.ts b/server/migrations/1650455299630-OrganizationEnableSignup.ts new file mode 100644 index 0000000000..68bc972711 --- /dev/null +++ b/server/migrations/1650455299630-OrganizationEnableSignup.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class OrganizationEnableSignup1650455299630 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('organizations', [ + new TableColumn({ + name: 'enable_sign_up', + type: 'boolean', + default: false, + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/server/migrations/1650485473528-PopulateSSOConfigs.ts b/server/migrations/1650485473528-PopulateSSOConfigs.ts new file mode 100644 index 0000000000..d22725d66d --- /dev/null +++ b/server/migrations/1650485473528-PopulateSSOConfigs.ts @@ -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 { + 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 {} +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 5072ba0236..2a50da9fb7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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({ diff --git a/server/src/controllers/app.controller.ts b/server/src/controllers/app.controller.ts index bd1b5b6cbd..bd625fc52c 100644 --- a/server/src/controllers/app.controller.ts +++ b/server/src/controllers/app.controller.ts @@ -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') diff --git a/server/src/controllers/app_users.controller.ts b/server/src/controllers/app_users.controller.ts index eced2f52e0..361bb3df5e 100644 --- a/server/src/controllers/app_users.controller.ts +++ b/server/src/controllers/app_users.controller.ts @@ -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'); diff --git a/server/src/controllers/apps.controller.ts b/server/src/controllers/apps.controller.ts index d41e270a88..7e1c7d694e 100644 --- a/server/src/controllers/apps.controller.ts +++ b/server/src/controllers/apps.controller.ts @@ -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); } } diff --git a/server/src/controllers/comment.controller.ts b/server/src/controllers/comment.controller.ts index da6cfdf164..6ef13c54ee 100644 --- a/server/src/controllers/comment.controller.ts +++ b/server/src/controllers/comment.controller.ts @@ -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 { + @Post() + public async createComment(@User() user, @Body() createCommentDto: CreateCommentDto): Promise { 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 { + public async getComments(@User() user, @Param('threadId') threadId: string, @Query() query): Promise { 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 { - const ability = await this.commentsAbilityFactory.appsActions(req.user, { id: appId }); + public async getNotifications(@User() user, @Param('appId') appId: string, @Query() query): Promise { + 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 { @@ -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'); diff --git a/server/src/controllers/data_queries.controller.ts b/server/src/controllers/data_queries.controller.ts index d20dd71cbe..8ec9d43553 100644 --- a/server/src/controllers/data_queries.controller.ts +++ b/server/src/controllers/data_queries.controller.ts @@ -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 { + async create(@User() user, @Body() dataQueryDto: CreateDataQueryDto): Promise { 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 = { diff --git a/server/src/controllers/data_sources.controller.ts b/server/src/controllers/data_sources.controller.ts index 2908a27aa6..b008ddb238 100644 --- a/server/src/controllers/data_sources.controller.ts +++ b/server/src/controllers/data_sources.controller.ts @@ -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'); diff --git a/server/src/controllers/group_permissions.controller.ts b/server/src/controllers/group_permissions.controller.ts index e4f39c332b..1a8a44ccca 100644 --- a/server/src/controllers/group_permissions.controller.ts +++ b/server/src/controllers/group_permissions.controller.ts @@ -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 }); } diff --git a/server/src/controllers/library_apps.controller.ts b/server/src/controllers/library_apps.controller.ts index 45cbe3a90a..74c6a032d8 100644 --- a/server/src/controllers/library_apps.controller.ts +++ b/server/src/controllers/library_apps.controller.ts @@ -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 }; } } diff --git a/server/src/controllers/organization_users.controller.ts b/server/src/controllers/organization_users.controller.ts index 80f99150f3..df769cf139 100644 --- a/server/src/controllers/organization_users.controller.ts +++ b/server/src/controllers/organization_users.controller.ts @@ -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 }); } } diff --git a/server/src/controllers/organizations.controller.ts b/server/src/controllers/organizations.controller.ts index e587d90a0b..ef560507ae 100644 --- a/server/src/controllers/organizations.controller.ts +++ b/server/src/controllers/organizations.controller.ts @@ -1,16 +1,87 @@ -import { Controller, Get, Request, UseGuards } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Param, Patch, Post, Request, UseGuards } from '@nestjs/common'; import { OrganizationsService } from '@services/organizations.service'; import { decamelizeKeys } from 'humps'; +import { User } from 'src/decorators/user.decorator'; import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard'; +import { AuthService } from '@services/auth.service'; +import { AppAbility } from 'src/modules/casl/casl-ability.factory'; +import { CheckPolicies } from 'src/modules/casl/check_policies.decorator'; +import { PoliciesGuard } from 'src/modules/casl/policies.guard'; +import { User as UserEntity } from 'src/entities/user.entity'; +import { ConfigService } from '@nestjs/config'; +import { MultiOrganizationGuard } from 'src/modules/auth/multi-organization.guard'; @Controller('organizations') export class OrganizationsController { - constructor(private organizationsService: OrganizationsService) {} + constructor( + private organizationsService: OrganizationsService, + private authService: AuthService, + private readonly configService: ConfigService + ) {} @UseGuards(JwtAuthGuard) @Get('users') - async create(@Request() req) { + async getUsers(@Request() req) { const result = await this.organizationsService.fetchUsers(req.user); return decamelizeKeys({ users: result }); } + + @UseGuards(JwtAuthGuard) + @Get() + async get(@User() user) { + const result = await this.organizationsService.fetchOrganisations(user); + return decamelizeKeys({ organizations: result }); + } + + @UseGuards(JwtAuthGuard, MultiOrganizationGuard) + @Post() + async create(@Body('name') name, @User() user) { + if (!name) { + throw new BadRequestException('name can not be empty'); + } + const result = await this.organizationsService.create(name, user); + + if (!result) { + throw new Error(); + } + return await this.authService.switchOrganization(result.id, user, true); + } + + @Get(['/:organizationId/public-configs', '/public-configs']) + async getOrganizationDetails(@Param('organizationId') organizationId: string) { + if (!organizationId && this.configService.get('MULTI_ORGANIZATION') !== 'true') { + // Request from single organization login page - find one from organization and setting + organizationId = (await this.organizationsService.getSingleOrganization()).id; + } + if (!organizationId) { + throw new BadRequestException(); + } + + const result = await this.organizationsService.fetchOrganisationDetails(organizationId, [true], true); + return decamelizeKeys({ ssoConfigs: result }); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('updateOrganizations', UserEntity)) + @Get('/configs') + async getConfigs(@User() user) { + const result = await this.organizationsService.fetchOrganisationDetails(user.organizationId); + return decamelizeKeys({ organizationDetails: result }); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('updateOrganizations', UserEntity)) + @Patch() + async update(@Body() body, @User() user) { + await this.organizationsService.updateOrganization(user.organizationId, body); + return {}; + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('updateOrganizations', UserEntity)) + @Patch('/configs') + async updateConfigs(@Body() body, @User() user) { + const result: any = await this.organizationsService.updateOrganizationConfigs(user.organizationId, body); + return decamelizeKeys({ id: result.id }); + } } diff --git a/server/src/controllers/thread.controller.ts b/server/src/controllers/thread.controller.ts index ae28505eb1..2d2a33c0e4 100644 --- a/server/src/controllers/thread.controller.ts +++ b/server/src/controllers/thread.controller.ts @@ -1,6 +1,5 @@ import { Controller, - Request, Post, Body, Get, @@ -16,43 +15,44 @@ import { CreateThreadDto, UpdateThreadDto } from '../dto/thread.dto'; import { Thread } from '../entities/thread.entity'; import { JwtAuthGuard } from '../modules/auth/jwt-auth.guard'; import { ThreadsAbilityFactory } from 'src/modules/casl/abilities/threads-ability.factory'; +import { User } from 'src/decorators/user.decorator'; @Controller('threads') export class ThreadController { constructor(private threadService: ThreadService, private threadsAbilityFactory: ThreadsAbilityFactory) {} @UseGuards(JwtAuthGuard) - @Post('create') - public async createThread(@Request() req, @Body() createThreadDto: CreateThreadDto): Promise { - const ability = await this.threadsAbilityFactory.appsActions(req.user, { id: createThreadDto.appId }); + @Post() + public async createThread(@User() user, @Body() createThreadDto: CreateThreadDto): Promise { + const ability = await this.threadsAbilityFactory.appsActions(user, createThreadDto.appId); if (!ability.can('createThread', Thread)) { throw new ForbiddenException('You do not have permissions to perform this action'); } - const thread = await this.threadService.createThread(createThreadDto, req.user.id, req.user.organization.id); + const thread = await this.threadService.createThread(createThreadDto, user.id, user.organizationId); return thread; } @UseGuards(JwtAuthGuard) @Get('/:appId/all') - public async getThreads(@Request() req, @Param('appId') appId: string, @Query() query): Promise { - const ability = await this.threadsAbilityFactory.appsActions(req.user, { id: appId }); + public async getThreads(@User() user, @Param('appId') appId: string, @Query() query): Promise { + const ability = await this.threadsAbilityFactory.appsActions(user, appId); if (!ability.can('fetchThreads', Thread)) { throw new ForbiddenException('You do not have permissions to perform this action'); } - const threads = await this.threadService.getThreads(appId, req.user.organization.id, query.appVersionsId); + const threads = await this.threadService.getThreads(appId, user.organizationId, query.appVersionsId); return threads; } @UseGuards(JwtAuthGuard) @Get('/:threadId') - public async getThread(@Param('threadId') threadId: number, @Request() req) { + public async getThread(@Param('threadId') threadId: number, @User() user) { const _response = await Thread.findOne({ where: { id: threadId }, }); - const ability = await this.threadsAbilityFactory.appsActions(req.user, { id: _response.appId }); + const ability = await this.threadsAbilityFactory.appsActions(user, _response.appId); if (!ability.can('fetchThreads', Thread)) { throw new ForbiddenException('You do not have permissions to perform this action'); @@ -62,17 +62,17 @@ export class ThreadController { } @UseGuards(JwtAuthGuard) - @Patch('/edit/:threadId') + @Patch('/:threadId') public async editThread( @Body() updateThreadDto: UpdateThreadDto, @Param('threadId') threadId: string, - @Request() req + @User() user ): Promise { const _response = await Thread.findOne({ where: { id: threadId }, }); - const ability = await this.threadsAbilityFactory.appsActions(req.user, { id: _response.appId }); + const ability = await this.threadsAbilityFactory.appsActions(user, _response.appId); if (!ability.can('updateThread', Thread)) { throw new ForbiddenException('You do not have permissions to perform this action'); @@ -82,13 +82,13 @@ export class ThreadController { } @UseGuards(JwtAuthGuard) - @Delete('/delete/:threadId') - public async deleteThread(@Param('threadId') threadId: string, @Request() req) { + @Delete('/:threadId') + public async deleteThread(@Param('threadId') threadId: string, @User() user) { const _response = await Thread.findOne({ where: { id: threadId }, }); - const ability = await this.threadsAbilityFactory.appsActions(req.user, { id: _response.appId }); + const ability = await this.threadsAbilityFactory.appsActions(user, _response.appId); if (!ability.can('deleteThread', Thread)) { throw new ForbiddenException('You do not have permissions to perform this action'); diff --git a/server/src/controllers/users.controller.ts b/server/src/controllers/users.controller.ts index 6fb11c0bea..b78fd9b735 100644 --- a/server/src/controllers/users.controller.ts +++ b/server/src/controllers/users.controller.ts @@ -1,39 +1,46 @@ -import { Body, Controller, Post, Patch, Request, UseGuards } from '@nestjs/common'; +import { Body, Controller, Post, Patch, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from 'src/modules/auth/jwt-auth.guard'; import { PasswordRevalidateGuard } from 'src/modules/auth/password-revalidate.guard'; import { UsersService } from 'src/services/users.service'; +import { User } from 'src/decorators/user.decorator'; +import { MultiOrganizationGuard } from 'src/modules/auth/multi-organization.guard'; +import { SignupDisableGuard } from 'src/modules/auth/signup-disable.guard'; import { CreateUserDto, UpdateUserDto } from '@dto/user.dto'; +import { AcceptInviteDto } from '@dto/accept-organization-invite.dto'; @Controller('users') export class UsersController { constructor(private usersService: UsersService) {} + @UseGuards(MultiOrganizationGuard, SignupDisableGuard) @Post('set_password_from_token') async create(@Body() userCreateDto: CreateUserDto) { - const result = await this.usersService.setupAccountFromInvitationToken(userCreateDto); - return result; + await this.usersService.setupAccountFromInvitationToken(userCreateDto); + return {}; + } + + @Post('accept-invite') + async acceptInvite(@Body() acceptInviteDto: AcceptInviteDto) { + await this.usersService.acceptOrganizationInvite(acceptInviteDto); + return {}; } @UseGuards(JwtAuthGuard) @Patch('update') - async update(@Request() req, @Body() updateUserDto: UpdateUserDto) { - const { first_name, last_name } = updateUserDto; - await this.usersService.update(req.user.id, { - firstName: first_name, - lastName: last_name, - }); - await req.user.reload(); + async update(@User() user, @Body() updateUserDto: UpdateUserDto) { + const { first_name: firstName, last_name: lastName } = updateUserDto; + await this.usersService.update(user.id, { firstName, lastName }); + await user.reload(); return { - first_name: req.user.firstName, - last_name: req.user.lastName, + first_name: user.firstName, + last_name: user.lastName, }; } @UseGuards(JwtAuthGuard, PasswordRevalidateGuard) @Patch('change_password') - async changePassword(@Request() req, @Body() body) { - const { newPassword } = body; - return await this.usersService.update(req.user.id, { + async changePassword(@User() user, @Body('newPassword') newPassword) { + return await this.usersService.update(user.id, { password: newPassword, }); } diff --git a/server/src/decorators/user.decorator.ts b/server/src/decorators/user.decorator.ts new file mode 100644 index 0000000000..3970fdeb54 --- /dev/null +++ b/server/src/decorators/user.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const User = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; +}); diff --git a/server/src/dto/accept-organization-invite.dto.ts b/server/src/dto/accept-organization-invite.dto.ts new file mode 100644 index 0000000000..5ba68d32bc --- /dev/null +++ b/server/src/dto/accept-organization-invite.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsOptional, IsNotEmpty } from 'class-validator'; + +export class AcceptInviteDto { + @IsString() + @IsOptional() + @IsNotEmpty() + password: string; + + @IsString() + @IsNotEmpty() + token: string; +} diff --git a/server/src/dto/user.dto.ts b/server/src/dto/user.dto.ts index 338dda9f85..e2e3f62176 100644 --- a/server/src/dto/user.dto.ts +++ b/server/src/dto/user.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsNotEmpty, IsBoolean } from 'class-validator'; +import { IsString, IsOptional, IsNotEmpty } from 'class-validator'; import { Transform } from 'class-transformer'; import { sanitizeInput } from 'src/helpers/utils.helper'; import { PartialType } from '@nestjs/mapped-types'; @@ -34,10 +34,6 @@ export class CreateUserDto { @IsOptional() @Transform(({ value }) => sanitizeInput(value)) role: string; - - @IsBoolean() - @IsOptional() - new_signup: boolean; } export class UpdateUserDto extends PartialType(CreateUserDto) {} diff --git a/server/src/entities/organization.entity.ts b/server/src/entities/organization.entity.ts index b81630b723..530bdea0a9 100644 --- a/server/src/entities/organization.entity.ts +++ b/server/src/entities/organization.entity.ts @@ -6,12 +6,14 @@ import { UpdateDateColumn, OneToMany, JoinColumn, + BaseEntity, } from 'typeorm'; import { GroupPermission } from './group_permission.entity'; -import { User } from './user.entity'; +import { SSOConfigs } from './sso_config.entity'; +import { OrganizationUser } from './organization_user.entity'; @Entity({ name: 'organizations' }) -export class Organization { +export class Organization extends BaseEntity { @PrimaryGeneratedColumn('uuid') id: string; @@ -21,6 +23,9 @@ export class Organization { @Column({ name: 'domain' }) domain: string; + @Column({ name: 'enable_sign_up' }) + enableSignUp: boolean; + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) createdAt: Date; @@ -31,7 +36,9 @@ export class Organization { @JoinColumn({ name: 'organization_id' }) groupPermissions: GroupPermission[]; - @OneToMany(() => User, (user) => user.organization) - @JoinColumn({ name: 'organization_id' }) - users: User[]; + @OneToMany(() => SSOConfigs, (ssoConfigs) => ssoConfigs.organization, { cascade: ['insert'] }) + ssoConfigs: SSOConfigs[]; + + @OneToMany(() => OrganizationUser, (organizationUser) => organizationUser.organization) + organizationUsers: OrganizationUser[]; } diff --git a/server/src/entities/organization_user.entity.ts b/server/src/entities/organization_user.entity.ts index 59530ff9e3..016a311c6c 100644 --- a/server/src/entities/organization_user.entity.ts +++ b/server/src/entities/organization_user.entity.ts @@ -28,6 +28,9 @@ export class OrganizationUser extends BaseEntity { @Column({ name: 'user_id' }) userId: string; + @Column({ name: 'invitation_token' }) + invitationToken: string; + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) createdAt: Date; diff --git a/server/src/entities/sso_config.entity.ts b/server/src/entities/sso_config.entity.ts new file mode 100644 index 0000000000..96c44c1caa --- /dev/null +++ b/server/src/entities/sso_config.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Organization } from './organization.entity'; + +type Google = { + clientId: string; +}; +type Git = { + clientId: string; + clientSecret: string; +}; +@Entity({ name: 'sso_configs' }) +export class SSOConfigs { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'organization_id' }) + organizationId: string; + + @Column({ name: 'sso' }) + sso: 'google' | 'git' | 'form'; + + @Column({ type: 'json' }) + configs: Google | Git; + + @Column({ name: 'enabled' }) + enabled: boolean; + + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => Organization, (organization) => organization.id) + @JoinColumn({ name: 'organization_id' }) + organization: Organization; +} diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 9302085bf2..db6062a5f8 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -7,14 +7,11 @@ import { BeforeInsert, BeforeUpdate, OneToMany, - ManyToOne, - JoinColumn, BaseEntity, ManyToMany, JoinTable, } from 'typeorm'; import { GroupPermission } from './group_permission.entity'; -import { Organization } from './organization.entity'; const bcrypt = require('bcrypt'); import { OrganizationUser } from './organization_user.entity'; import { UserGroupPermission } from './user_group_permission.entity'; @@ -51,17 +48,11 @@ export class User extends BaseEntity { password: string; @Column({ name: 'organization_id' }) - organizationId: string; + defaultOrganizationId: string; @Column({ name: 'role' }) role: string; - @Column({ name: 'sso_id' }) - ssoId: string; - - @Column({ name: 'sso' }) - sso: string; - @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) createdAt: Date; @@ -71,10 +62,6 @@ export class User extends BaseEntity { @OneToMany(() => OrganizationUser, (organizationUser) => organizationUser.user, { eager: true }) organizationUsers: OrganizationUser[]; - @ManyToOne(() => Organization, (organization) => organization.id) - @JoinColumn({ name: 'organization_id' }) - organization: Organization; - @ManyToMany(() => GroupPermission) @JoinTable({ name: 'user_group_permissions', @@ -89,4 +76,7 @@ export class User extends BaseEntity { @OneToMany(() => UserGroupPermission, (userGroupPermission) => userGroupPermission.user, { onDelete: 'CASCADE' }) userGroupPermissions: UserGroupPermission[]; + + organizationId: string; + isPasswordLogin: boolean; } diff --git a/server/src/events/events.module.ts b/server/src/events/events.module.ts index 14566e4b67..f805d81481 100644 --- a/server/src/events/events.module.ts +++ b/server/src/events/events.module.ts @@ -3,8 +3,18 @@ import { EventsGateway } from './events.gateway'; import { YjsGateway } from './yjs.gateway'; import { AuthModule } from 'src/modules/auth/auth.module'; +const providers = []; + +if (process.env.COMMENT_FEATURE_ENABLE !== 'false') { + providers.unshift(EventsGateway); +} + +if (process.env.ENABLE_MULTIPLAYER_EDITING !== 'false') { + providers.unshift(YjsGateway); +} + @Module({ imports: [AuthModule], - providers: [EventsGateway, YjsGateway], + providers, }) export class EventsModule {} diff --git a/server/src/helpers/utils.helper.ts b/server/src/helpers/utils.helper.ts index 259126b467..2cb1f971c4 100644 --- a/server/src/helpers/utils.helper.ts +++ b/server/src/helpers/utils.helper.ts @@ -31,6 +31,16 @@ export async function getCachedConnection(dataSourceId, dataSourceUpdatedAt): Pr } } +export function cleanObject(obj: any): any { + // This will remove undefined properties, for self and its children + Object.keys(obj).forEach((key) => { + obj[key] === undefined && delete obj[key]; + if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { + cleanObject(obj[key]); + } + }); +} + export function sanitizeInput(value: string) { return sanitizeHtml(value, { allowedTags: [], diff --git a/server/src/main.ts b/server/src/main.ts index 7bfa31b883..e902fa503c 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -22,10 +22,7 @@ async function bootstrap() { app.useLogger(app.get(Logger)); app.useGlobalFilters(new AllExceptionsFilter(app.get(Logger))); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - - if (process.env.COMMENT_FEATURE_ENABLE !== 'false') { - app.useWebSocketAdapter(new WsAdapter(app)); - } + app.useWebSocketAdapter(new WsAdapter(app)); app.setGlobalPrefix('api'); app.enableCors(); diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts index 6fe0312511..2ba7b73159 100644 --- a/server/src/modules/auth/auth.module.ts +++ b/server/src/modules/auth/auth.module.ts @@ -17,12 +17,26 @@ import { OauthService, GoogleOAuthService, GitOAuthService } from '@ee/services/ import { OauthController } from '@ee/controllers/oauth.controller'; import { GroupPermission } from 'src/entities/group_permission.entity'; import { App } from 'src/entities/app.entity'; +import { SSOConfigs } from 'src/entities/sso_config.entity'; +import { GroupPermissionsService } from '@services/group_permissions.service'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { EncryptionService } from '@services/encryption.service'; @Module({ imports: [ UsersModule, PassportModule, - TypeOrmModule.forFeature([User, Organization, OrganizationUser, GroupPermission, App]), + TypeOrmModule.forFeature([ + User, + Organization, + OrganizationUser, + GroupPermission, + App, + SSOConfigs, + AppGroupPermission, + UserGroupPermission, + ]), JwtModule.registerAsync({ useFactory: (config: ConfigService) => { return { @@ -45,6 +59,8 @@ import { App } from 'src/entities/app.entity'; OauthService, GoogleOAuthService, GitOAuthService, + GroupPermissionsService, + EncryptionService, ], controllers: [OauthController], exports: [AuthService], diff --git a/server/src/modules/auth/jwt.strategy.ts b/server/src/modules/auth/jwt.strategy.ts index 10b18b3da8..7417f5eaa1 100644 --- a/server/src/modules/auth/jwt.strategy.ts +++ b/server/src/modules/auth/jwt.strategy.ts @@ -3,6 +3,7 @@ import { PassportStrategy } from '@nestjs/passport'; import { Injectable } from '@nestjs/common'; import { UsersService } from '../../../src/services/users.service'; import { ConfigService } from '@nestjs/config'; +import { User } from 'src/entities/user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -15,7 +16,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: any) { - const user = await this.usersService.findByEmail(payload.sub); + if (!payload.organizationId) return false; + const user: User = await this.usersService.findByEmail(payload.sub, payload.organizationId); + if (!user) return false; + + user.organizationId = payload.organizationId; + user.isPasswordLogin = payload.isPasswordLogin; if (user && (await this.usersService.status(user)) !== 'archived') return user; else return false; diff --git a/server/src/modules/auth/multi-organization.guard.ts b/server/src/modules/auth/multi-organization.guard.ts new file mode 100644 index 0000000000..ef84dbefd4 --- /dev/null +++ b/server/src/modules/auth/multi-organization.guard.ts @@ -0,0 +1,12 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable } from 'rxjs'; + +@Injectable() +export class MultiOrganizationGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean | Promise | Observable { + return this.configService.get('MULTI_ORGANIZATION') === 'true'; + } +} diff --git a/server/src/modules/auth/password-login-disabled.guard.ts b/server/src/modules/auth/password-login-disabled.guard.ts deleted file mode 100644 index 97069aa7ad..0000000000 --- a/server/src/modules/auth/password-login-disabled.guard.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { Observable } from 'rxjs'; - -@Injectable() -export class PasswordLoginDisabledGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean | Promise | Observable { - return process.env.DISABLE_PASSWORD_LOGIN != 'true'; - } -} diff --git a/server/src/modules/auth/signup-disable.guard.ts b/server/src/modules/auth/signup-disable.guard.ts new file mode 100644 index 0000000000..ceaeec6325 --- /dev/null +++ b/server/src/modules/auth/signup-disable.guard.ts @@ -0,0 +1,12 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Observable } from 'rxjs'; + +@Injectable() +export class SignupDisableGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean | Promise | Observable { + return this.configService.get('DISABLE_SIGNUPS') !== 'true'; + } +} diff --git a/server/src/modules/casl/abilities/apps-ability.factory.ts b/server/src/modules/casl/abilities/apps-ability.factory.ts index 9755c721f7..aa59364f6d 100644 --- a/server/src/modules/casl/abilities/apps-ability.factory.ts +++ b/server/src/modules/casl/abilities/apps-ability.factory.ts @@ -38,7 +38,7 @@ export type AppsAbility = Ability<[Actions, Subjects]>; export class AppsAbilityFactory { constructor(private usersService: UsersService) {} - async appsActions(user: User, params: any) { + async appsActions(user: User, id?: string) { const { can, build } = new AbilityBuilder>(Ability as AbilityClass); if (await this.usersService.userCan(user, 'create', 'User')) { @@ -50,7 +50,7 @@ export class AppsAbilityFactory { can('cloneApp', App, { organizationId: user.organizationId }); } - if (await this.usersService.userCan(user, 'read', 'App', params.id)) { + if (await this.usersService.userCan(user, 'read', 'App', id)) { can('viewApp', App, { organizationId: user.organizationId }); can('fetchUsers', App, { organizationId: user.organizationId }); @@ -67,7 +67,7 @@ export class AppsAbilityFactory { }); } - if (await this.usersService.userCan(user, 'update', 'App', params.id)) { + if (await this.usersService.userCan(user, 'update', 'App', id)) { can('updateParams', App, { organizationId: user.organizationId }); can('createVersions', App, { organizationId: user.organizationId }); can('deleteVersions', App, { organizationId: user.organizationId }); @@ -83,7 +83,7 @@ export class AppsAbilityFactory { can('deleteDataSource', App, { organizationId: user.organizationId }); } - if (await this.usersService.userCan(user, 'delete', 'App', params.id)) { + if (await this.usersService.userCan(user, 'delete', 'App', id)) { can('deleteApp', App, { organizationId: user.organizationId }); } diff --git a/server/src/modules/casl/abilities/threads-ability.factory.ts b/server/src/modules/casl/abilities/threads-ability.factory.ts index 0905a9c810..1e79d169ea 100644 --- a/server/src/modules/casl/abilities/threads-ability.factory.ts +++ b/server/src/modules/casl/abilities/threads-ability.factory.ts @@ -14,22 +14,22 @@ export type ThreadsAbility = Ability<[Actions, Subjects]>; export class ThreadsAbilityFactory { constructor(private usersService: UsersService) {} - async appsActions(user: User, params: any) { + async appsActions(user: User, id: string) { const { can, build } = new AbilityBuilder>(Ability as AbilityClass); - if (await this.usersService.userCan(user, 'create', 'Thread', params.id)) { + if (await this.usersService.userCan(user, 'create', 'Thread', id)) { can('createThread', Thread, { organizationId: user.organizationId }); } - if (await this.usersService.userCan(user, 'read', 'Thread', params.id)) { + if (await this.usersService.userCan(user, 'read', 'Thread', id)) { can('fetchThreads', Thread, { organizationId: user.organizationId }); } - if (await this.usersService.userCan(user, 'update', 'Thread', params.id)) { + if (await this.usersService.userCan(user, 'update', 'Thread', id)) { can('updateThread', Thread, { organizationId: user.organizationId }); } - if (await this.usersService.userCan(user, 'delete', 'Thread', params.id)) { + if (await this.usersService.userCan(user, 'delete', 'Thread', id)) { can('deleteThread', Thread, { organizationId: user.organizationId }); } diff --git a/server/src/modules/casl/casl-ability.factory.ts b/server/src/modules/casl/casl-ability.factory.ts index 2181e4c2ec..3c8dfca270 100644 --- a/server/src/modules/casl/casl-ability.factory.ts +++ b/server/src/modules/casl/casl-ability.factory.ts @@ -4,7 +4,7 @@ import { InferSubjects, AbilityBuilder, Ability, AbilityClass, ExtractSubjectTyp import { Injectable } from '@nestjs/common'; import { UsersService } from '@services/users.service'; -type Actions = 'changeRole' | 'archiveUser' | 'inviteUser' | 'accessGroupPermission'; +type Actions = 'changeRole' | 'archiveUser' | 'inviteUser' | 'accessGroupPermission' | 'updateOrganizations'; type Subjects = InferSubjects | 'all'; @@ -23,6 +23,7 @@ export class CaslAbilityFactory { can('archiveUser', User); can('changeRole', User); can('accessGroupPermission', User); + can('updateOrganizations', User); } return build({ diff --git a/server/src/modules/organizations/organizations.module.ts b/server/src/modules/organizations/organizations.module.ts index 8d0612fe2a..d27061eed3 100644 --- a/server/src/modules/organizations/organizations.module.ts +++ b/server/src/modules/organizations/organizations.module.ts @@ -12,10 +12,49 @@ import { CaslModule } from '../casl/casl.module'; import { EmailService } from '@services/email.service'; import { GroupPermission } from 'src/entities/group_permission.entity'; import { App } from 'src/entities/app.entity'; +import { SSOConfigs } from 'src/entities/sso_config.entity'; +import { AuthService } from '@services/auth.service'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { GroupPermissionsService } from '@services/group_permissions.service'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { EncryptionService } from '@services/encryption.service'; @Module({ - imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User, GroupPermission, App]), CaslModule], - providers: [OrganizationsService, OrganizationUsersService, UsersService, EmailService], + imports: [ + TypeOrmModule.forFeature([ + Organization, + OrganizationUser, + User, + GroupPermission, + App, + SSOConfigs, + AppGroupPermission, + UserGroupPermission, + ]), + CaslModule, + JwtModule.registerAsync({ + useFactory: (config: ConfigService) => { + return { + secret: config.get('SECRET_KEY_BASE'), + signOptions: { + expiresIn: config.get('JWT_EXPIRATION_TIME') || '30d', + }, + }; + }, + inject: [ConfigService], + }), + ], + providers: [ + OrganizationsService, + OrganizationUsersService, + UsersService, + EmailService, + AuthService, + GroupPermissionsService, + EncryptionService, + ], controllers: [OrganizationsController, OrganizationUsersController], }) export class OrganizationsModule {} diff --git a/server/src/services/app_config.service.ts b/server/src/services/app_config.service.ts index 3992a9f438..066c7b43e2 100644 --- a/server/src/services/app_config.service.ts +++ b/server/src/services/app_config.service.ts @@ -22,9 +22,8 @@ export class AppConfigService { 'APM_VENDOR', 'SENTRY_DNS', 'SENTRY_DEBUG', - 'SSO_GOOGLE_OAUTH2_CLIENT_ID', - 'SSO_GIT_OAUTH2_CLIENT_ID', - 'DISABLE_PASSWORD_LOGIN', + 'DISABLE_SIGNUPS', + 'MULTI_ORGANIZATION', ]; } diff --git a/server/src/services/apps.service.ts b/server/src/services/apps.service.ts index 2b631ec0fb..607478fd76 100644 --- a/server/src/services/apps.service.ts +++ b/server/src/services/apps.service.ts @@ -15,6 +15,7 @@ import { UsersService } from './users.service'; import { AppImportExportService } from './app_import_export.service'; import { DataSourcesService } from './data_sources.service'; import { Credential } from 'src/entities/credential.entity'; +import { cleanObject } from 'src/helpers/utils.helper'; import { AppUpdateDto } from '@dto/app-update.dto'; @Injectable() @@ -82,7 +83,7 @@ export class AppsService { name: 'Untitled app', createdAt: new Date(), updatedAt: new Date(), - organizationId: user.organization.id, + organizationId: user.organizationId, user: user, }) ); @@ -152,15 +153,15 @@ export class AppsService { 'user_group_permissions', 'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id' ) - .where( + .where('apps.organization_id = :organizationId', { organizationId: user.organizationId }) + .andWhere( new Brackets((qb) => { qb.where('user_group_permissions.user_id = :userId', { userId: user.id, }) .andWhere('app_group_permissions.read = :value', { value: true }) - .orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', { + .orWhere('apps.is_public = :value OR apps.user_id = :userId', { value: true, - organizationId: user.organizationId, userId: user.id, }); }) @@ -183,15 +184,15 @@ export class AppsService { 'user_group_permissions', 'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id' ) - .where( + .where('apps.organization_id = :organizationId', { organizationId: user.organizationId }) + .andWhere( new Brackets((qb) => { qb.where('user_group_permissions.user_id = :userId', { userId: user.id, }) .andWhere('app_group_permissions.read = :value', { value: true }) - .orWhere('(apps.is_public = :value AND apps.organization_id = :organizationId) OR apps.user_id = :userId', { + .orWhere('apps.is_public = :value OR apps.user_id = :userId', { value: true, - organizationId: user.organizationId, userId: user.id, }); }) @@ -229,9 +230,7 @@ export class AppsService { }; // removing keys with undefined values - Object.keys(updateableParams).forEach((key) => - updateableParams[key] === undefined ? delete updateableParams[key] : {} - ); + cleanObject(updateableParams); return await this.appsRepository.update(appId, updateableParams); } diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 8fe05b662a..61653c45b3 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, UnauthorizedException, NotAcceptableException } from '@nestjs/common'; +import { Injectable, NotAcceptableException, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { UsersService } from './users.service'; import { OrganizationsService } from './organizations.service'; import { JwtService } from '@nestjs/jwt'; @@ -6,7 +6,9 @@ import { User } from '../entities/user.entity'; import { OrganizationUsersService } from './organization_users.service'; import { EmailService } from './email.service'; import { decamelizeKeys } from 'humps'; -import { AppAuthenticationDto } from '@dto/app-authentication.dto'; +import { Organization } from 'src/entities/organization.entity'; +import { ConfigService } from '@nestjs/config'; +import { SSOConfigs } from 'src/entities/sso_config.entity'; const bcrypt = require('bcrypt'); const uuid = require('uuid'); @@ -17,7 +19,8 @@ export class AuthService { private jwtService: JwtService, private organizationsService: OrganizationsService, private organizationUsersService: OrganizationUsersService, - private emailService: EmailService + private emailService: EmailService, + private configService: ConfigService ) {} verifyToken(token: string) { @@ -29,8 +32,9 @@ export class AuthService { } } - async validateUser(email: string, password: string): Promise { - const user = await this.usersService.findByEmail(email); + private async validateUser(email: string, password: string, organisationId?: string): Promise { + const user = await this.usersService.findByEmail(email, organisationId); + if (!user) return null; const isVerified = await bcrypt.compare(password, user.password); @@ -38,11 +42,64 @@ export class AuthService { return isVerified ? user : null; } - async login(appAuthDto: AppAuthenticationDto) { - const user = await this.validateUser(appAuthDto.email, appAuthDto.password); + async login(email: string, password: string, organizationId?: string) { + let organization: Organization; + + const user = await this.validateUser(email, password, organizationId); if (user && (await this.usersService.status(user)) !== 'archived') { - const payload = { username: user.id, sub: user.email }; + if (!organizationId) { + // Global login + // Determine the organization to be loaded + if (this.configService.get('MULTI_ORGANIZATION') !== 'true') { + // Single organization + organization = await this.organizationsService.getSingleOrganization(); + if (!organization?.ssoConfigs?.find((oc) => oc.sso == 'form' && oc.enabled)) { + throw new UnauthorizedException(); + } + } else { + const organizationList: Organization[] = await this.organizationsService.findOrganizationSupportsFormLogin( + user + ); + + const defaultOrgDetails: Organization = organizationList?.find((og) => og.id === user.defaultOrganizationId); + // Multi organization + if (defaultOrgDetails) { + // default organization form login enabled + organization = defaultOrgDetails; + } else if (organizationList?.length > 0) { + // default organization form login not enabled, picking first one from form enabled list + organization = organizationList[0]; + } else { + // no form login enabled organization available for user - creating new one + organization = await this.organizationsService.create('Untitled organization', user); + } + } + user.organizationId = organization.id; + } else { + // organization specific login + user.organizationId = organizationId; + + organization = await this.organizationsService.get(user.organizationId); + const formConfigs: SSOConfigs = organization?.ssoConfigs?.find((sso) => sso.sso === 'form'); + + if (!formConfigs?.enabled) { + // no configurations in organization side or Form login disabled for the organization + throw new UnauthorizedException('Password login is disabled for the organization'); + } + } + + if (user.defaultOrganizationId !== user.organizationId) { + // Updating default organization Id + await this.usersService.updateDefaultOrganization(user, organization.id); + } + + const payload = { + username: user.id, + sub: user.email, + organizationId: user.organizationId, + isPasswordLogin: true, + }; return decamelizeKeys({ id: user.id, @@ -50,6 +107,8 @@ export class AuthService { email: user.email, first_name: user.firstName, last_name: user.lastName, + organizationId: user.organizationId, + 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), @@ -59,23 +118,79 @@ export class AuthService { } } - async signup(appAuthDto: AppAuthenticationDto) { - // Check if the installation allows user signups - if (process.env.DISABLE_SIGNUPS === 'true') { - return {}; + async switchOrganization(newOrganizationId: string, user: User, isNewOrganization?: boolean) { + if (!(isNewOrganization || user.isPasswordLogin)) { + throw new UnauthorizedException(); } + if (this.configService.get('MULTI_ORGANIZATION') !== 'true') { + throw new UnauthorizedException(); + } + const newUser = await this.usersService.findByEmail(user.email, newOrganizationId); - const { email } = appAuthDto; + if (newUser && (await this.usersService.status(newUser)) !== 'archived') { + newUser.organizationId = newOrganizationId; + + const organization: Organization = await this.organizationsService.get(newUser.organizationId); + + const formConfigs: SSOConfigs = organization?.ssoConfigs?.find((sso) => sso.sso === 'form'); + + if (!formConfigs?.enabled) { + // no configurations in organization side or Form login disabled for the organization + throw new UnauthorizedException('Password login disabled for the organization'); + } + + // Updating default organization Id + await this.usersService.updateDefaultOrganization(newUser, newUser.organizationId); + + const payload = { + username: user.id, + sub: user.email, + organizationId: newUser.organizationId, + isPasswordLogin: true, + }; + + return decamelizeKeys({ + id: newUser.id, + auth_token: this.jwtService.sign(payload), + email: newUser.email, + first_name: newUser.firstName, + last_name: newUser.lastName, + organizationId: newUser.organizationId, + organization: organization.name, + admin: await this.usersService.hasGroup(newUser, 'admin'), + group_permissions: await this.usersService.groupPermissions(newUser), + app_group_permissions: await this.usersService.appGroupPermissions(newUser), + }); + } else { + throw new UnauthorizedException('Invalid credentials'); + } + } + + async signup(email: string) { const existingUser = await this.usersService.findByEmail(email); - if (existingUser) { + if (existingUser?.invitationToken || existingUser?.organizationUsers?.some((ou) => ou.status === 'active')) { throw new NotAcceptableException('Email already exists'); } - const organization = await this.organizationsService.create('Untitled organization'); - const user = await this.usersService.create({ email }, organization, ['all_users', 'admin']); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const organizationUser = await this.organizationUsersService.create(user, organization); + let organization: Organization; + // Check if the configs allows user signups + if (this.configService.get('MULTI_ORGANIZATION') !== 'true') { + // Single organization checking if organization exist + organization = await this.organizationsService.getSingleOrganization(); + if (organization) { + throw new NotAcceptableException('Multi organization not supported - organization exist'); + } + } else { + // Multi organization + if (this.configService.get('DISABLE_SIGNUPS') === 'true') { + throw new NotAcceptableException(); + } + } + // Create default organization + organization = await this.organizationsService.create('Untitled organization'); + const user = await this.usersService.create({ email }, organization.id, ['all_users', 'admin'], existingUser, true); + await this.organizationUsersService.create(user, organization, true); await this.emailService.sendWelcomeEmail(user.email, user.firstName, user.invitationToken); return {}; diff --git a/server/src/services/data_queries.service.ts b/server/src/services/data_queries.service.ts index f2633a37ae..2095cbe38e 100644 --- a/server/src/services/data_queries.service.ts +++ b/server/src/services/data_queries.service.ts @@ -31,7 +31,7 @@ export class DataQueriesService { return await this.dataQueriesRepository.find({ where: whereClause, - order: { name: 'ASC' }, + order: { createdAt: 'DESC' }, // Latest query should be on top }); } diff --git a/server/src/services/data_sources.service.ts b/server/src/services/data_sources.service.ts index d72fd92c21..4b41edb022 100644 --- a/server/src/services/data_sources.service.ts +++ b/server/src/services/data_sources.service.ts @@ -5,6 +5,7 @@ import { getManager, Repository } from 'typeorm'; import { User } from '../../src/entities/user.entity'; import { DataSource } from '../../src/entities/data_source.entity'; import { CredentialsService } from './credentials.service'; +import { cleanObject } from 'src/helpers/utils.helper'; @Injectable() export class DataSourcesService { @@ -59,9 +60,7 @@ export class DataSourcesService { }; // Remove keys with undefined values - Object.keys(updateableParams).forEach((key) => - updateableParams[key] === undefined ? delete updateableParams[key] : {} - ); + cleanObject(updateableParams); return this.dataSourcesRepository.save(updateableParams); } diff --git a/server/src/services/email.service.ts b/server/src/services/email.service.ts index e37aec3563..5a0a6dfd05 100644 --- a/server/src/services/email.service.ts +++ b/server/src/services/email.service.ts @@ -55,7 +55,7 @@ export class EmailService { async sendWelcomeEmail(to: string, name: string, invitationtoken: string) { const subject = 'Welcome to ToolJet'; - const inviteUrl = `${this.TOOLJET_HOST}/invitations/${invitationtoken}?signup=true`; + const inviteUrl = `${this.TOOLJET_HOST}/invitations/${invitationtoken}`; const html = ` @@ -81,9 +81,15 @@ export class EmailService { await this.sendEmail(to, subject, html); } - async sendOrganizationUserWelcomeEmail(to: string, name: string, sender: string, invitationtoken: string) { + async sendOrganizationUserWelcomeEmail( + to: string, + name: string, + sender: string, + invitationtoken: string, + organisationName: string + ) { const subject = 'Welcome to ToolJet'; - const inviteUrl = `${this.TOOLJET_HOST}/invitations/${invitationtoken}`; + const inviteUrl = `${this.TOOLJET_HOST}/organization-invitations/${invitationtoken}`; const html = ` @@ -94,7 +100,7 @@ export class EmailService {

Hi ${name || ''},


- ${sender} has invited you to use ToolJet. Use the link below to set up your account and get started. + ${sender} has invited you to use ToolJet organisation ${organisationName}. Use the link below to set up your account and get started.
${inviteUrl} diff --git a/server/src/services/folder_apps.service.ts b/server/src/services/folder_apps.service.ts index e3823927a4..31f1d6819f 100644 --- a/server/src/services/folder_apps.service.ts +++ b/server/src/services/folder_apps.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { FolderApp } from '../../src/entities/folder_app.entity'; @@ -11,6 +11,14 @@ export class FolderAppsService { ) {} async create(folderId: string, appId: string): Promise { + const existingFolderApp = await this.folderAppsRepository.findOne({ + where: { appId, folderId }, + }); + + if (existingFolderApp) { + throw new BadRequestException('App has been already added to the folder'); + } + const newFolderApp = this.folderAppsRepository.create({ folderId, appId, diff --git a/server/src/services/group_permissions.service.ts b/server/src/services/group_permissions.service.ts index 8fcf857357..d3979f4c10 100644 --- a/server/src/services/group_permissions.service.ts +++ b/server/src/services/group_permissions.service.ts @@ -165,7 +165,7 @@ export class GroupPermissionsService { const params = { removeGroups: [groupPermission.group], }; - await this.usersService.update(userId, params, manager); + await this.usersService.update(userId, params, manager, user.organizationId); } } @@ -174,7 +174,7 @@ export class GroupPermissionsService { const params = { addGroups: [groupPermission.group], }; - await this.usersService.update(userId, params, manager); + await this.usersService.update(userId, params, manager, user.organizationId); } } }); @@ -272,9 +272,23 @@ export class GroupPermissionsService { .getMany(); const adminUserIds = adminUsers.map((u) => u.userId); - return await this.userRepository.find({ - id: Not(In([...usersInGroupIds, ...adminUserIds])), - organizationId: user.organizationId, - }); + return await createQueryBuilder(User, 'user') + .innerJoin( + 'user.organizationUsers', + 'organization_users', + 'organization_users.organizationId = :organizationId', + { organizationId: user.organizationId } + ) + .where('user.id NOT IN (:...userList)', { userList: [...usersInGroupIds, ...adminUserIds] }) + .getMany(); + } + + async createUserGroupPermission(userId: string, groupPermissionId: string) { + await this.userGroupPermissionsRepository.save( + this.userGroupPermissionsRepository.create({ + userId, + groupPermissionId, + }) + ); } } diff --git a/server/src/services/organization_users.service.ts b/server/src/services/organization_users.service.ts index db080f1bcd..d27f0f7ed5 100644 --- a/server/src/services/organization_users.service.ts +++ b/server/src/services/organization_users.service.ts @@ -1,12 +1,13 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from '../entities/user.entity'; -import { getManager, Repository } from 'typeorm'; -import { Organization } from 'src/entities/organization.entity'; +import { createQueryBuilder, getManager, Repository } from 'typeorm'; import { UsersService } from 'src/services/users.service'; import { OrganizationUser } from 'src/entities/organization_user.entity'; import { BadRequestException } from '@nestjs/common'; import { EmailService } from './email.service'; +import { Organization } from 'src/entities/organization.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; import { InviteNewUserDto } from '@dto/invite-new-user.dto'; const uuid = require('uuid'); @@ -19,7 +20,7 @@ export class OrganizationUsersService { private emailService: EmailService ) {} - async findOne(id: string): Promise { + async findOrganization(id: string): Promise { return await this.organizationUsersRepository.findOne({ where: { id } }); } @@ -30,40 +31,55 @@ export class OrganizationUsersService { email: inviteNewUserDto.email, }; - const existingUser = await this.usersService.findByEmail(userParams.email); - if (existingUser) { + let user = await this.usersService.findByEmail(userParams.email); + + if (user?.organizationUsers?.some((ou) => ou.organizationId === currentUser.organizationId)) { throw new BadRequestException('User with such email already exists.'); } - const user = await this.usersService.create(userParams, currentUser.organization, ['all_users']); - const organizationUser = await this.create(user, currentUser.organization); + + if (user?.invitationToken) { + // user sign up not completed, name will be empty - updating name + await this.usersService.update(user.id, { firstName: userParams.firstName, lastName: userParams.lastName }); + } + + user = await this.usersService.create(userParams, currentUser.organizationId, ['all_users'], user); + + const currentOrganization: Organization = ( + await this.organizationUsersRepository.findOne({ + where: { userId: currentUser.id, organizationId: currentUser.organizationId }, + relations: ['organization'], + }) + )?.organization; + + const organizationUser: OrganizationUser = await this.create(user, currentOrganization, true); await this.emailService.sendOrganizationUserWelcomeEmail( user.email, user.firstName, currentUser.firstName, - user.invitationToken + organizationUser.invitationToken, + currentOrganization.name ); return organizationUser; } - async create(user: User, organization: Organization): Promise { + async create(user: User, organization: Organization, isInvite?: boolean): Promise { return await this.organizationUsersRepository.save( this.organizationUsersRepository.create({ user, organization, - role: 'all_users', + invitationToken: isInvite ? uuid.v4() : null, + status: isInvite ? 'invited' : 'active', + role: 'all-users', createdAt: new Date(), updatedAt: new Date(), }) ); } - async changeRole(user: User, id: string, role: string) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const organizationUser = await this.organizationUsersRepository.findOne({ - where: { id }, - }); + async changeRole(id: string, role: string) { + const organizationUser = await this.organizationUsersRepository.findOne({ where: { id } }); if (organizationUser.role == 'admin') { const lastActiveAdmin = await this.lastActiveAdmin(organizationUser.organizationId); @@ -83,10 +99,9 @@ export class OrganizationUsersService { where: { id: organizationUser.userId }, }); - await this.usersService.throwErrorIfRemovingLastActiveAdmin(user); + await this.usersService.throwErrorIfRemovingLastActiveAdmin(user, undefined, organizationUser.organizationId); - await manager.update(User, user.id, { invitationToken: null }); - await manager.update(OrganizationUser, id, { status: 'archived' }); + await manager.update(OrganizationUser, id, { status: 'archived', invitationToken: null }); }); return true; @@ -98,23 +113,31 @@ export class OrganizationUsersService { }); if (organizationUser.status !== 'archived') return false; + const invitationToken = uuid.v4(); + await getManager().transaction(async (manager) => { await manager.update(OrganizationUser, organizationUser.id, { status: 'invited', + invitationToken, }); - await manager.update(User, organizationUser.userId, { - invitationToken: uuid.v4(), - password: uuid.v4(), - }); + await manager.update(User, organizationUser.userId, { password: uuid.v4() }); }); const updatedUser = await this.usersService.findOne(organizationUser.userId); + const currentOrganization: Organization = ( + await this.organizationUsersRepository.findOne({ + where: { userId: user.id, organizationId: user.organizationId }, + relations: ['organization'], + }) + )?.organization; + await this.emailService.sendOrganizationUserWelcomeEmail( updatedUser.email, updatedUser.firstName, user.firstName, - updatedUser.invitationToken + invitationToken, + currentOrganization.name ); return true; @@ -133,12 +156,10 @@ export class OrganizationUsersService { } async activeAdminCount(organizationId: string) { - return await this.organizationUsersRepository.count({ - where: { - organizationId: organizationId, - role: 'admin', - status: 'active', - }, - }); + return await createQueryBuilder(GroupPermission, 'group_permissions') + .innerJoin('group_permissions.userGroupPermission', 'user_group_permission') + .where('group_permissions.group = :admin', { admin: 'admin' }) + .andWhere('group_permissions.organization = :organizationId', { organizationId }) + .getCount(); } } diff --git a/server/src/services/organizations.service.ts b/server/src/services/organizations.service.ts index 080952a378..20cf667a90 100644 --- a/server/src/services/organizations.service.ts +++ b/server/src/services/organizations.service.ts @@ -1,37 +1,70 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { OrganizationUser } from '../entities/organization_user.entity'; -import { Repository } from 'typeorm'; -import { Organization } from 'src/entities/organization.entity'; -import { UsersService } from './users.service'; import { GroupPermission } from 'src/entities/group_permission.entity'; +import { Organization } from 'src/entities/organization.entity'; +import { SSOConfigs } from 'src/entities/sso_config.entity'; +import { User } from 'src/entities/user.entity'; +import { cleanObject } from 'src/helpers/utils.helper'; +import { createQueryBuilder, Repository } from 'typeorm'; +import { OrganizationUser } from '../entities/organization_user.entity'; +import { EncryptionService } from './encryption.service'; +import { GroupPermissionsService } from './group_permissions.service'; +import { OrganizationUsersService } from './organization_users.service'; +import { UsersService } from './users.service'; @Injectable() export class OrganizationsService { constructor( @InjectRepository(Organization) private organizationsRepository: Repository, + @InjectRepository(SSOConfigs) + private ssoConfigRepository: Repository, @InjectRepository(OrganizationUser) private organizationUsersRepository: Repository, @InjectRepository(GroupPermission) private groupPermissionsRepository: Repository, - private usersService: UsersService + private usersService: UsersService, + private organizationUserService: OrganizationUsersService, + private groupPermissionService: GroupPermissionsService, + private encryptionService: EncryptionService ) {} - async create(name: string): Promise { + async create(name: string, user?: User): Promise { const organization = await this.organizationsRepository.save( this.organizationsRepository.create({ + ssoConfigs: [ + { + sso: 'form', + enabled: true, + }, + ], name, createdAt: new Date(), updatedAt: new Date(), }) ); - await this.createDefaultGroupPermissionsForOrganization(organization); + const createdGroupPermissions = await this.createDefaultGroupPermissionsForOrganization(organization); + + if (user) { + await this.organizationUserService.create(user, organization, false); + + for (const groupPermission of createdGroupPermissions) { + await this.groupPermissionService.createUserGroupPermission(user.id, groupPermission.id); + } + } return organization; } + async get(id: string): Promise { + return await this.organizationsRepository.findOne({ where: { id }, relations: ['ssoConfigs'] }); + } + + async getSingleOrganization(): Promise { + return await this.organizationsRepository.findOne({ relations: ['ssoConfigs'] }); + } + async createDefaultGroupPermissionsForOrganization(organization: Organization) { const defaultGroups = ['all_users', 'admin']; const createdGroupPermissions = []; @@ -58,6 +91,8 @@ export class OrganizationsService { relations: ['user'], }); + const isAdmin = await this.usersService.hasGroup(user, 'admin'); + // serialize const serializedUsers = []; for (const orgUser of organizationUsers) { @@ -71,17 +106,206 @@ export class OrganizationsService { status: orgUser.status, }; - if ((await this.usersService.hasGroup(user, 'admin')) && orgUser.user.invitationToken) - serializedUser['invitationToken'] = orgUser.user.invitationToken; - + if (isAdmin && orgUser.invitationToken) { + serializedUser['invitationToken'] = orgUser.invitationToken; + } serializedUsers.push(serializedUser); } return serializedUsers; } - async findFirst(): Promise { - const organizations = await this.organizationsRepository.find(); - return organizations[0]; + async fetchOrganisations(user: any): Promise { + return await createQueryBuilder(Organization, 'organization') + .innerJoin( + 'organization.organizationUsers', + 'organisation_users', + 'organisation_users.status IN(:...statusList)', + { + statusList: ['active'], + } + ) + .andWhere('organisation_users.userId = :userId', { + userId: user.id, + }) + .orderBy('name', 'ASC') + .getMany(); + } + + async findOrganizationSupportsFormLogin(user: any): Promise { + return await createQueryBuilder(Organization, 'organization') + .innerJoin('organization.ssoConfigs', 'organisation_sso', 'organisation_sso.sso = :form', { + form: 'form', + }) + .innerJoin( + 'organization.organizationUsers', + 'organisation_users', + 'organisation_users.status IN(:...statusList)', + { + statusList: ['active'], + } + ) + .where('organisation_sso.enabled = :enabled', { + enabled: true, + }) + .andWhere('organisation_users.userId = :userId', { + userId: user.id, + }) + .orderBy('name', 'ASC') + .getMany(); + } + + async getSSOConfigs(organizationId: string, sso: string): Promise { + return await createQueryBuilder(Organization, 'organization') + .leftJoinAndSelect('organization.ssoConfigs', 'organisation_sso', 'organisation_sso.sso = :sso', { + sso, + }) + .andWhere('organization.id = :organizationId', { + organizationId, + }) + .getOne(); + } + + async fetchOrganisationDetails( + organizationId: string, + statusList?: Array, + isHideSensitiveData?: boolean + ): Promise { + const result = await createQueryBuilder(Organization, 'organization') + .innerJoinAndSelect( + 'organization.ssoConfigs', + 'organisation_sso', + 'organisation_sso.enabled IN (:...statusList)', + { + statusList: statusList || [true, false], // Return enabled and disabled sso if status list not passed + } + ) + .andWhere('organization.id = :organizationId', { + organizationId, + }) + .getOne(); + + if (!(result?.ssoConfigs?.length > 0)) { + return; + } + + for (const sso of result?.ssoConfigs) { + await this.decryptSecret(sso?.configs); + } + + if (!isHideSensitiveData) { + return result; + } + return this.hideSSOSensitiveData(result?.ssoConfigs, result?.name); + } + + private hideSSOSensitiveData(ssoConfigs: SSOConfigs[], organizationName): any { + const configs = { name: organizationName }; + if (ssoConfigs?.length > 0) { + for (const config of ssoConfigs) { + const configId = config['id']; + delete config['id']; + delete config['organizationId']; + delete config['createdAt']; + delete config['updatedAt']; + + configs[config.sso] = this.buildConfigs(config, configId); + } + } + return configs; + } + + private buildConfigs(config: any, configId: string) { + if (!config) return config; + return { + ...config, + configs: { + ...(config?.configs || {}), + ...(config?.configs ? { clientSecret: '' } : {}), + }, + configId, + }; + } + + private async encryptSecret(configs) { + if (!configs || typeof configs !== 'object') return configs; + await Promise.all( + Object.keys(configs).map(async (key) => { + if (key.toLowerCase().includes('secret')) { + if (configs[key]) { + configs[key] = await this.encryptionService.encryptColumnValue('ssoConfigs', key, configs[key]); + } + } + }) + ); + } + + private async decryptSecret(configs) { + if (!configs || typeof configs !== 'object') return configs; + await Promise.all( + Object.keys(configs).map(async (key) => { + if (key.toLowerCase().includes('secret')) { + if (configs[key]) { + configs[key] = await this.encryptionService.decryptColumnValue('ssoConfigs', key, configs[key]); + } + } + }) + ); + } + + async updateOrganization(organizationId: string, params) { + const { name, domain, enableSignUp } = params; + + const updateableParams = { + name, + domain, + enableSignUp, + }; + + // removing keys with undefined values + cleanObject(updateableParams); + + return await this.organizationsRepository.update(organizationId, updateableParams); + } + + async updateOrganizationConfigs(organizationId: string, params: any) { + const { type, configs, enabled } = params; + + if (!(type && ['git', 'google', 'form'].includes(type))) { + throw new BadRequestException(); + } + + await this.encryptSecret(configs); + const organization: Organization = await this.getSSOConfigs(organizationId, type); + + if (organization?.ssoConfigs?.length > 0) { + const ssoConfigs: SSOConfigs = organization.ssoConfigs[0]; + + const updateableParams = { + configs, + enabled, + }; + + // removing keys with undefined values + cleanObject(updateableParams); + return await this.ssoConfigRepository.update(ssoConfigs.id, updateableParams); + } else { + const newSSOConfigs = this.ssoConfigRepository.create({ + organization, + sso: type, + configs, + enabled: !!enabled, + }); + return await this.ssoConfigRepository.save(newSSOConfigs); + } + } + + async getConfigs(id: string): Promise { + const result: SSOConfigs = await this.ssoConfigRepository.findOne({ + where: { id, enabled: true }, + relations: ['organization'], + }); + await this.decryptSecret(result?.configs); + return result; } } diff --git a/server/src/services/seeds.service.ts b/server/src/services/seeds.service.ts index 9f053202ef..fe8599e721 100644 --- a/server/src/services/seeds.service.ts +++ b/server/src/services/seeds.service.ts @@ -24,6 +24,12 @@ export class SeedsService { } const organization = manager.create(Organization, { + ssoConfigs: [ + { + enabled: true, + sso: 'form', + }, + ], name: 'My organization', }); @@ -34,8 +40,9 @@ export class SeedsService { lastName: 'Developer', email: 'dev@tooljet.io', password: 'password', - organizationId: organization.id, + defaultOrganizationId: organization.id, }); + user.organizationId = organization.id; await manager.save(user); diff --git a/server/src/services/users.service.ts b/server/src/services/users.service.ts index 42297d6fd5..38b9ec6c96 100644 --- a/server/src/services/users.service.ts +++ b/server/src/services/users.service.ts @@ -9,6 +9,7 @@ import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; import { GroupPermission } from 'src/entities/group_permission.entity'; import { BadRequestException } from '@nestjs/common'; +import { cleanObject } from 'src/helpers/utils.helper'; import { CreateUserDto } from '@dto/user.dto'; const uuid = require('uuid'); const bcrypt = require('bcrypt'); @@ -30,17 +31,23 @@ export class UsersService { return this.usersRepository.findOne({ where: { id } }); } - async findByEmail(email: string): Promise { - return this.usersRepository.findOne({ - where: { email }, - relations: ['organization'], - }); - } - - async findBySSOId(ssoId: string): Promise { - return this.usersRepository.findOne({ - where: { ssoId }, - }); + async findByEmail(email: string, organisationId?: string): Promise { + if (!organisationId) { + return this.usersRepository.findOne({ + where: { email }, + }); + } else { + return await createQueryBuilder(User, 'users') + .innerJoinAndSelect( + 'users.organizationUsers', + 'organization_users', + 'organization_users.organizationId = :organisationId', + { organisationId } + ) + .where('organization_users.status = :active', { active: 'active' }) + .andWhere('users.email = :email', { email }) + .getOne(); + } } async findByPasswordResetToken(token: string): Promise { @@ -49,32 +56,48 @@ export class UsersService { }); } - async create(userParams: any, organization: Organization, groups?: string[]): Promise { + async create( + userParams: any, + organizationId: string, + groups?: string[], + existingUser?: User, + isInvite?: boolean + ): Promise { const password = uuid.v4(); - const invitationToken = uuid.v4(); - const { email, firstName, lastName, ssoId, sso } = userParams; + const { email, firstName, lastName } = userParams; let user: User; await getManager().transaction(async (manager) => { - user = manager.create(User, { - email, - firstName, - lastName, - password, - invitationToken, - ssoId, - sso, - organizationId: organization.id, - createdAt: new Date(), - updatedAt: new Date(), - }); - await manager.save(user); + if (!existingUser) { + user = manager.create(User, { + email, + firstName, + lastName, + password, + invitationToken: isInvite ? uuid.v4() : null, + defaultOrganizationId: organizationId, + createdAt: new Date(), + updatedAt: new Date(), + }); + await manager.save(user); + } else { + if (isInvite) { + // user already invited to an organization, but not active - user tries to sign up + await manager.save( + Object.assign(existingUser, { + invitationToken: uuid.v4(), + defaultOrganizationId: organizationId, + }) + ); + } + user = existingUser; + } for (const group of groups) { const orgGroupPermission = await manager.findOne(GroupPermission, { where: { - organizationId: organization.id, + organizationId: organizationId, group: group, }, }); @@ -94,75 +117,123 @@ export class UsersService { return user; } - async updateSSODetails(user: User, { userSSOId, sso }) { - await this.usersRepository.save({ - ...user, - ssoId: userSSOId, - sso, - }); - } - async status(user: User) { const orgUser = await this.organizationUsersRepository.findOne({ where: { user } }); return orgUser.status; } - async findOrCreateByEmail( - userParams: any, - organization: Organization - ): Promise<{ user: User; newUserCreated: boolean }> { + async findOrCreateByEmail(userParams: any, organizationId: string): Promise<{ user: User; newUserCreated: boolean }> { let user: User; let newUserCreated = false; user = await this.findByEmail(userParams.email); - if (!user) { - const groups = ['all_users']; - user = await this.create({ ...userParams }, organization, groups); - newUserCreated = true; + if (user?.organizationUsers?.some((ou) => ou.organizationId === organizationId)) { + // User exist in current organization + return { user, newUserCreated }; } + const groups = ['all_users']; + user = await this.create({ ...userParams }, organizationId, groups, user); + newUserCreated = true; + return { user, newUserCreated }; } async setupAccountFromInvitationToken(userCreateDto: CreateUserDto) { - const { organization, password, token, role } = userCreateDto; - const firstName = userCreateDto.first_name; - const lastName = userCreateDto.last_name; - const newSignup = userCreateDto.new_signup; + const { organization, password, token, role, first_name: firstName, last_name: lastName } = userCreateDto; if (!token) { throw new BadRequestException('Invalid token'); } - const user = await this.usersRepository.findOne({ where: { invitationToken: token } }); + const user: User = await this.usersRepository.findOne({ where: { invitationToken: token } }); - if (user) { - // beforeUpdate hook will not trigger if using update method of repository - await this.usersRepository.save( - Object.assign(user, { - firstName, - lastName, - password, - role, - invitationToken: null, - }) - ); + if (!user?.organizationUsers) { + throw new BadRequestException('Invalid invitation link'); + } + const organizationUser: OrganizationUser = user.organizationUsers.find( + (ou) => ou.organizationId === user.defaultOrganizationId + ); - const organizationUser = user.organizationUsers[0]; - await this.organizationUsersRepository.update(organizationUser.id, { + if (!organizationUser) { + throw new BadRequestException('Invalid invitation link'); + } + + await this.usersRepository.save( + Object.assign(user, { + firstName, + lastName, + password, + role, + invitationToken: null, + }) + ); + + await this.organizationUsersRepository.save( + Object.assign(organizationUser, { + invitationToken: null, status: 'active', - }); + }) + ); - if (newSignup) { - await this.organizationsRepository.update(user.organizationId, { - name: organization, - }); - } + if (organization) { + await this.organizationsRepository.update(user.defaultOrganizationId, { + name: organization, + }); } } - async update(userId: string, params: any, manager?: EntityManager) { + async acceptOrganizationInvite(params: any) { + const { password, token } = params; + + const organizationUser = await this.organizationUsersRepository.findOne({ + where: { invitationToken: token }, + relations: ['user'], + }); + + if (!organizationUser?.user) { + throw new BadRequestException('Invalid invitation link'); + } + const user: User = organizationUser.user; + + if (user.invitationToken) { + // User sign up link send - not activated account + const defaultOrganizationUser = await this.organizationUsersRepository.findOne({ + where: { organizationId: user.defaultOrganizationId, status: 'invited' }, + }); + + if (defaultOrganizationUser) { + await this.organizationUsersRepository.save( + Object.assign(defaultOrganizationUser, { + invitationToken: null, + status: 'active', + }) + ); + } + } + + // set new password if entered + await this.usersRepository.save( + Object.assign(user, { + ...(password ? { password } : {}), + invitationToken: null, + }) + ); + + await this.organizationUsersRepository.save( + Object.assign(organizationUser, { + invitationToken: null, + status: 'active', + }) + ); + } + + async updateDefaultOrganization(user: User, organizationId: string) { + await this.usersRepository.update(user.id, { defaultOrganizationId: organizationId }); + } + + async update(userId: string, params: any, manager?: EntityManager, organizationId?: string) { const { forgotPasswordToken, password, firstName, lastName, addGroups, removeGroups } = params; const hashedPassword = password ? bcrypt.hashSync(password, 10) : undefined; @@ -175,9 +246,7 @@ export class UsersService { }; // removing keys with undefined values - Object.keys(updateableParams).forEach((key) => - updateableParams[key] === undefined ? delete updateableParams[key] : {} - ); + cleanObject(updateableParams); let user: User; @@ -185,9 +254,9 @@ export class UsersService { await manager.update(User, userId, { ...updateableParams }); user = await manager.findOne(User, { where: { id: userId } }); - await this.removeUserGroupPermissionsIfExists(manager, user, removeGroups); + await this.removeUserGroupPermissionsIfExists(manager, user, removeGroups, organizationId); - await this.addUserGroupPermissions(manager, user, addGroups); + await this.addUserGroupPermissions(manager, user, addGroups, organizationId); }; if (manager) { @@ -201,9 +270,10 @@ export class UsersService { return user; } - async addUserGroupPermissions(manager: EntityManager, user: User, addGroups: string[]) { + async addUserGroupPermissions(manager: EntityManager, user: User, addGroups: string[], organizationId?: string) { + const orgId = organizationId || user.defaultOrganizationId; if (addGroups) { - const orgGroupPermissions = await this.groupPermissionsForOrganization(user.organizationId); + const orgGroupPermissions = await this.groupPermissionsForOrganization(orgId); for (const group of addGroups) { const orgGroupPermission = orgGroupPermissions.find((permission) => permission.group == group); @@ -221,16 +291,22 @@ export class UsersService { } } - async removeUserGroupPermissionsIfExists(manager: EntityManager, user: User, removeGroups: string[]) { + async removeUserGroupPermissionsIfExists( + manager: EntityManager, + user: User, + removeGroups: string[], + organizationId?: string + ) { + const orgId = organizationId || user.defaultOrganizationId; if (removeGroups) { - await this.throwErrorIfRemovingLastActiveAdmin(user, removeGroups); + await this.throwErrorIfRemovingLastActiveAdmin(user, removeGroups, orgId); if (removeGroups.includes('all_users')) { throw new BadRequestException('Cannot remove user from default group.'); } const groupPermissions = await manager.find(GroupPermission, { group: In(removeGroups), - organizationId: user.organizationId, + organizationId: orgId, }); const groupIdsToMaybeRemove = groupPermissions.map((permission) => permission.id); @@ -241,7 +317,7 @@ export class UsersService { } } - async throwErrorIfRemovingLastActiveAdmin(user: User, removeGroups: string[] = ['admin']) { + async throwErrorIfRemovingLastActiveAdmin(user: User, removeGroups: string[] = ['admin'], organizationId: string) { const removingAdmin = removeGroups.includes('admin'); if (!removingAdmin) return; @@ -252,7 +328,7 @@ export class UsersService { .andWhere('organization_users.status = :status', { status: 'active' }) .andWhere('group_permissions.group = :group', { group: 'admin' }) .andWhere('group_permissions.organization_id = :organizationId', { - organizationId: user.organizationId, + organizationId, }) .getCount(); @@ -260,8 +336,6 @@ export class UsersService { } async hasGroup(user: User, group: string, organizationId?: string): Promise { - // Currently user can be part of single organization and - // the organization id is present on the user itself const orgId = organizationId || user.organizationId; const result = await createQueryBuilder(GroupPermission, 'group_permissions') @@ -289,7 +363,7 @@ export class UsersService { return await this.canUserPerformActionOnApp(user, 'update', resourceId); case 'Folder': - return await this.canUserPerformActionOnFolder(user, action, resourceId); + return await this.canUserPerformActionOnFolder(user, action); default: return false; @@ -323,7 +397,7 @@ export class UsersService { return permissionGrant; } - async canUserPerformActionOnFolder(user: User, action: string, folderId?: string): Promise { + async canUserPerformActionOnFolder(user: User, action: string): Promise { let permissionGrant: boolean; switch (action) { @@ -338,22 +412,22 @@ export class UsersService { return permissionGrant; } - async isUserOwnerOfApp(user, appId): Promise { - const app = await this.appsRepository.findOne({ + async isUserOwnerOfApp(user: User, appId: string): Promise { + const app: App = await this.appsRepository.findOne({ where: { id: appId, userId: user.id, }, }); - return !!app; + return !!app && app.organizationId === user.organizationId; } canAnyGroupPerformAction(action: string, permissions: AppGroupPermission[] | GroupPermission[]): boolean { return permissions.some((p) => p[action]); } - async groupPermissions(user: User, organizationId?: string): Promise { - const orgUserGroupPermissions = await this.userGroupPermissions(user, organizationId); + async groupPermissions(user: User): Promise { + const orgUserGroupPermissions = await this.userGroupPermissions(user, user.organizationId); const groupIds = orgUserGroupPermissions.map((p) => p.groupPermissionId); const groupPermissionRepository = getRepository(GroupPermission); @@ -366,26 +440,32 @@ export class UsersService { return await groupPermissionRepository.find({ organizationId }); } - async appGroupPermissions(user: User, appId?: string, organizationId?: string): Promise { - const orgUserGroupPermissions = await this.userGroupPermissions(user, organizationId); + async appGroupPermissions(user: User, appId?: string): Promise { + const orgUserGroupPermissions = await this.userGroupPermissions(user, user.organizationId); const groupIds = orgUserGroupPermissions.map((p) => p.groupPermissionId); - const appGroupPermissionRepository = getRepository(AppGroupPermission); + + if (!groupIds || groupIds.length === 0) { + return []; + } + + const query = createQueryBuilder(AppGroupPermission, 'app_group_permissions') + .innerJoin( + 'app_group_permissions.groupPermission', + 'group_permissions', + 'group_permissions.organization_id = :organizationId', + { + organizationId: user.organizationId, + } + ) + .where('app_group_permissions.groupPermissionId IN (:...groupIds)', { groupIds }); if (appId) { - return await appGroupPermissionRepository.find({ - groupPermissionId: In(groupIds), - appId: appId, - }); - } else { - return await appGroupPermissionRepository.find({ - groupPermissionId: In(groupIds), - }); + query.andWhere('app_group_permissions.appId = :appId', { appId }); } + return await query.getMany(); } async userGroupPermissions(user: User, organizationId?: string): Promise { - // Currently user can be part of single organization - // and hence we can use organization_id on user entity const orgId = organizationId || user.organizationId; return await createQueryBuilder(UserGroupPermission, 'user_group_permissions') diff --git a/server/test/controllers/app.e2e-spec.ts b/server/test/controllers/app.e2e-spec.ts index 4a16664a1a..74f9fa4f75 100644 --- a/server/test/controllers/app.e2e-spec.ts +++ b/server/test/controllers/app.e2e-spec.ts @@ -3,111 +3,394 @@ import * as request from 'supertest'; import { INestApplication } from '@nestjs/common'; import { getManager, Repository } from 'typeorm'; import { User } from 'src/entities/user.entity'; -import { clearDB, createUser, createNestAppInstance, authHeaderForUser } from '../test.helper'; +import { clearDB, createUser, authHeaderForUser, createNestAppInstanceWithEnvMock } from '../test.helper'; import { OrganizationUser } from 'src/entities/organization_user.entity'; +import { Organization } from 'src/entities/organization.entity'; +import { SSOConfigs } from 'src/entities/sso_config.entity'; import { EmailService } from '@services/email.service'; describe('Authentication', () => { let app: INestApplication; let userRepository: Repository; + let orgRepository: Repository; let orgUserRepository: Repository; - const originalEnv = process.env; + let ssoConfigsRepository: Repository; + let mockConfig; + let current_organization: Organization; + let current_user: User; beforeEach(async () => { await clearDB(); - await createUser(app, { email: 'admin@tooljet.io' }); }); beforeAll(async () => { - app = await createNestAppInstance(); + ({ app, mockConfig } = await createNestAppInstanceWithEnvMock()); userRepository = app.get('UserRepository'); + orgRepository = app.get('OrganizationRepository'); orgUserRepository = app.get('OrganizationUserRepository'); + ssoConfigsRepository = app.get('SSOConfigsRepository'); }); - it('should create new users', async () => { - const response = await request(app.getHttpServer()).post('/api/signup').send({ email: 'test@tooljet.io' }); - expect(response.statusCode).toBe(201); - - const user = await userRepository.findOne({ - where: { email: 'test@tooljet.io' }, - relations: ['organization'], - }); - - expect(user.organization.name).toBe('Untitled organization'); - - const groupPermissions = await user.groupPermissions; - const groupNames = groupPermissions.map((x) => x.group); - - expect(new Set(['all_users', 'admin'])).toEqual(new Set(groupNames)); - - const adminGroup = groupPermissions.find((x) => x.group == 'admin'); - expect(adminGroup.appCreate).toBeTruthy(); - expect(adminGroup.appDelete).toBeTruthy(); - expect(adminGroup.folderCreate).toBeTruthy(); - - const allUserGroup = groupPermissions.find((x) => x.group == 'all_users'); - expect(allUserGroup.appCreate).toBeFalsy(); - expect(allUserGroup.appDelete).toBeFalsy(); - expect(allUserGroup.folderCreate).toBeFalsy(); + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); }); - it('authenticate if valid credentials', async () => { - await request(app.getHttpServer()) - .post('/api/authenticate') - .send({ email: 'admin@tooljet.io', password: 'password' }) - .expect(201); - }); - - it('throw 401 if user is archived', async () => { - await createUser(app, { email: 'user@tooljet.io', status: 'archived' }); - - await request(app.getHttpServer()) - .post('/api/authenticate') - .send({ email: 'user@tooljet.io', password: 'password' }) - .expect(401); - - const adminUser = await userRepository.findOne({ - email: 'admin@tooljet.io', - }); - await orgUserRepository.update({ userId: adminUser.id }, { status: 'archived' }); - - await request(app.getHttpServer()) - .get('/api/organizations/users') - .set('Authorization', authHeaderForUser(adminUser)) - .expect(401); - }); - - it('throw 401 if invalid credentials', async () => { - await request(app.getHttpServer()) - .post('/api/authenticate') - .send({ email: 'amdin@tooljet.io', password: 'pwd' }) - .expect(401); - }); - - describe('if password login is disabled', () => { - beforeAll(async () => { - process.env = { ...originalEnv, DISABLE_PASSWORD_LOGIN: 'true' }; - }); - - it('should not create new users', async () => { + describe('Single organization', () => { + it('should create new users and organization', async () => { const response = await request(app.getHttpServer()).post('/api/signup').send({ email: 'test@tooljet.io' }); - expect(response.statusCode).toBe(403); - }); + expect(response.statusCode).toBe(201); - it('does not authenticate if valid credentials', async () => { - await request(app.getHttpServer()) - .post('/api/authenticate') - .send({ email: 'admin@tooljet.io', password: 'password' }) - .expect(403); - }); + const user = await userRepository.findOneOrFail({ + where: { email: 'test@tooljet.io' }, + relations: ['organizationUsers'], + }); - afterAll(async () => { - process.env = { ...originalEnv }; + const organization = await orgRepository.findOneOrFail({ + where: { id: user?.organizationUsers?.[0]?.organizationId }, + }); + + expect(user.defaultOrganizationId).toBe(user?.organizationUsers?.[0]?.organizationId); + expect(organization.name).toBe('Untitled organization'); + + const groupPermissions = await user.groupPermissions; + const groupNames = groupPermissions.map((x) => x.group); + + expect(new Set(['all_users', 'admin'])).toEqual(new Set(groupNames)); + + const adminGroup = groupPermissions.find((x) => x.group == 'admin'); + expect(adminGroup.appCreate).toBeTruthy(); + expect(adminGroup.appDelete).toBeTruthy(); + expect(adminGroup.folderCreate).toBeTruthy(); + + const allUserGroup = groupPermissions.find((x) => x.group == 'all_users'); + expect(allUserGroup.appCreate).toBeFalsy(); + expect(allUserGroup.appDelete).toBeFalsy(); + expect(allUserGroup.folderCreate).toBeFalsy(); + }); + describe('Single organization operations', () => { + beforeEach(async () => { + current_organization = (await createUser(app, { email: 'admin@tooljet.io' })).organization; + }); + it('should not create new users since organization already exist', async () => { + const response = await request(app.getHttpServer()).post('/api/signup').send({ email: 'test@tooljet.io' }); + expect(response.statusCode).toBe(406); + }); + it('authenticate if valid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(201); + }); + it('authenticate to organization if valid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate/' + current_organization.id) + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(201); + }); + it('throw unauthorized error if user not exist in given organization if valid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate/82249621-efc1-4cd2-9986-5c22182fa8a7') + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(401); + }); + it('throw 401 if user is archived', async () => { + await createUser(app, { email: 'user@tooljet.io', status: 'archived' }); + + await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'user@tooljet.io', password: 'password' }) + .expect(401); + + const adminUser = await userRepository.findOneOrFail({ + email: 'admin@tooljet.io', + }); + await orgUserRepository.update({ userId: adminUser.id }, { status: 'archived' }); + + await request(app.getHttpServer()) + .get('/api/organizations/users') + .set('Authorization', authHeaderForUser(adminUser)) + .expect(401); + }); + it('throw 401 if invalid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'amdin@tooljet.io', password: 'pwd' }) + .expect(401); + }); + it('should throw 401 if form login is disabled', async () => { + await ssoConfigsRepository.update({ organizationId: current_organization.id }, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(401); + }); + }); + }); + + describe('Multi organization', () => { + beforeEach(async () => { + const { organization, user } = await createUser(app, { + email: 'admin@tooljet.io', + firstName: 'user', + lastName: 'name', + }); + current_organization = organization; + current_user = user; + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'DISABLE_SIGNUPS': + return 'false'; + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + }); + describe('sign up disabled', () => { + beforeEach(async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'DISABLE_SIGNUPS': + return 'true'; + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + }); + it('should not create new users', async () => { + const response = await request(app.getHttpServer()).post('/api/signup').send({ email: 'test@tooljet.io' }); + expect(response.statusCode).toBe(406); + }); + }); + describe('sign up enabled and authorization', () => { + it('should create new users', async () => { + const response = await request(app.getHttpServer()).post('/api/signup').send({ email: 'test@tooljet.io' }); + expect(response.statusCode).toBe(201); + + const user = await userRepository.findOneOrFail({ + where: { email: 'test@tooljet.io' }, + relations: ['organizationUsers'], + }); + + const organization = await orgRepository.findOneOrFail({ + where: { id: user?.organizationUsers?.[0]?.organizationId }, + }); + + expect(user.defaultOrganizationId).toBe(user?.organizationUsers?.[0]?.organizationId); + expect(organization?.name).toBe('Untitled organization'); + + const groupPermissions = await user.groupPermissions; + const groupNames = groupPermissions.map((x) => x.group); + + expect(new Set(['all_users', 'admin'])).toEqual(new Set(groupNames)); + + const adminGroup = groupPermissions.find((x) => x.group == 'admin'); + expect(adminGroup.appCreate).toBeTruthy(); + expect(adminGroup.appDelete).toBeTruthy(); + expect(adminGroup.folderCreate).toBeTruthy(); + + const allUserGroup = groupPermissions.find((x) => x.group == 'all_users'); + expect(allUserGroup.appCreate).toBeFalsy(); + expect(allUserGroup.appDelete).toBeFalsy(); + expect(allUserGroup.folderCreate).toBeFalsy(); + }); + it('authenticate if valid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(201); + }); + it('authenticate to organization if valid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate/' + current_organization.id) + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(201); + }); + it('throw unauthorized error if user not exist in given organization if valid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate/82249621-efc1-4cd2-9986-5c22182fa8a7') + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(401); + }); + it('throw 401 if user is archived', async () => { + await createUser(app, { email: 'user@tooljet.io', status: 'archived' }); + + await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'user@tooljet.io', password: 'password' }) + .expect(401); + + const adminUser = await userRepository.findOneOrFail({ + email: 'admin@tooljet.io', + }); + await orgUserRepository.update({ userId: adminUser.id }, { status: 'archived' }); + + await request(app.getHttpServer()) + .get('/api/organizations/users') + .set('Authorization', authHeaderForUser(adminUser)) + .expect(401); + }); + it('throw 401 if invalid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'amdin@tooljet.io', password: 'pwd' }) + .expect(401); + }); + it('should throw 401 if form login is disabled', async () => { + await ssoConfigsRepository.update({ organizationId: current_organization.id }, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/authenticate/' + current_organization.id) + .send({ email: 'admin@tooljet.io', password: 'password' }) + .expect(401); + }); + it('should create new organization if login is disabled for default organization', async () => { + await ssoConfigsRepository.update({ organizationId: current_organization.id }, { enabled: false }); + const response = await request(app.getHttpServer()) + .post('/api/authenticate') + .send({ email: 'admin@tooljet.io', password: 'password' }); + expect(response.statusCode).toBe(201); + expect(response.body.organization_id).not.toBe(current_organization.id); + expect(response.body.organization).toBe('Untitled organization'); + }); + it('should be able to switch between organizations with admin privilage', async () => { + const { organization: invited_organization } = await createUser( + app, + { organizationName: 'New Organization' }, + current_user + ); + const response = await request(app.getHttpServer()) + .get('/api/switch/' + invited_organization.id) + .set('Authorization', authHeaderForUser(current_user)); + + expect(response.statusCode).toBe(200); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual(current_user.email); + expect(first_name).toEqual(current_user.firstName); + expect(last_name).toEqual(current_user.lastName); + expect(admin).toBeTruthy(); + expect(organization_id).toBe(invited_organization.id); + expect(organization).toBe(invited_organization.name); + expect(group_permissions).toHaveLength(2); + expect(group_permissions.some((gp) => gp.group === 'all_users')).toBeTruthy(); + expect(group_permissions.some((gp) => gp.group === 'admin')).toBeTruthy(); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + await current_user.reload(); + expect(current_user.defaultOrganizationId).toBe(invited_organization.id); + }); + it('should be able to switch between organizations with user privilage', async () => { + const { organization: invited_organization } = await createUser( + app, + { groups: ['all_users'], organizationName: 'New Organization' }, + current_user + ); + const response = await request(app.getHttpServer()) + .get('/api/switch/' + invited_organization.id) + .set('Authorization', authHeaderForUser(current_user)); + + expect(response.statusCode).toBe(200); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual(current_user.email); + expect(first_name).toEqual(current_user.firstName); + expect(last_name).toEqual(current_user.lastName); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(invited_organization.id); + expect(organization).toBe(invited_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + await current_user.reload(); + expect(current_user.defaultOrganizationId).toBe(invited_organization.id); + }); }); }); describe('POST /api/forgot_password', () => { + beforeEach(async () => { + await createUser(app, { + email: 'admin@tooljet.io', + firstName: 'user', + lastName: 'name', + }); + }); it('should return error if required params are not present', async () => { const response = await request(app.getHttpServer()).post('/api/forgot_password'); @@ -134,6 +417,13 @@ describe('Authentication', () => { }); describe('POST /api/reset_password', () => { + beforeEach(async () => { + await createUser(app, { + email: 'admin@tooljet.io', + firstName: 'user', + lastName: 'name', + }); + }); it('should return error if required params are not present', async () => { const response = await request(app.getHttpServer()).post('/api/reset_password'); diff --git a/server/test/controllers/apps.e2e-spec.ts b/server/test/controllers/apps.e2e-spec.ts index 800eda9245..a80ff6a243 100644 --- a/server/test/controllers/apps.e2e-spec.ts +++ b/server/test/controllers/apps.e2e-spec.ts @@ -99,7 +99,7 @@ describe('apps controller', () => { expect(response.body.name).toBe('Untitled app'); const appId = response.body.id; - const application = await App.findOne({ where: { id: appId } }); + const application = await App.findOneOrFail({ where: { id: appId } }); expect(application.name).toBe('Untitled app'); expect(application.id).toBe(application.slug); @@ -120,7 +120,7 @@ describe('apps controller', () => { groups: ['all_users', 'admin'], }); const organization = adminUserData.organization; - const allUserGroup = await getManager().findOne(GroupPermission, { + const allUserGroup = await getManager().findOneOrFail(GroupPermission, { where: { group: 'all_users', organization: adminUserData.organization, @@ -422,7 +422,7 @@ describe('apps controller', () => { expect(response.statusCode).toBe(201); const appId = response.body.id; - const clonedApplication = await App.findOne({ where: { id: appId } }); + const clonedApplication = await App.findOneOrFail({ where: { id: appId } }); expect(clonedApplication.name).toBe('App to clone'); response = await request(app.getHttpServer()) @@ -569,11 +569,11 @@ describe('apps controller', () => { expect(response.statusCode).toBe(200); - expect(await App.findOne({ where: { id: application.id } })).toBeUndefined(); - expect(await AppVersion.findOne({ where: { id: version.id } })).toBeUndefined(); - expect(await DataQuery.findOne({ where: { id: dataQuery.id } })).toBeUndefined(); - expect(await DataSource.findOne({ where: { id: dataSource.id } })).toBeUndefined(); - expect(await AppUser.findOne({ where: { appId: application.id } })).toBeUndefined(); + await expect(App.findOneOrFail({ where: { id: application.id } })).rejects.toThrow(expect.any(Error)); + await expect(AppVersion.findOneOrFail({ where: { id: version.id } })).rejects.toThrow(expect.any(Error)); + await expect(DataQuery.findOneOrFail({ where: { id: dataQuery.id } })).rejects.toThrow(expect.any(Error)); + await expect(DataSource.findOneOrFail({ where: { id: dataSource.id } })).rejects.toThrow(expect.any(Error)); + await expect(AppUser.findOneOrFail({ where: { appId: application.id } })).rejects.toThrow(expect.any(Error)); }); it('should be possible for app creator to delete an app', async () => { @@ -598,7 +598,7 @@ describe('apps controller', () => { .set('Authorization', authHeaderForUser(developer.user)); expect(response.statusCode).toBe(200); - expect(await App.findOne({ where: { id: application.id } })).toBeUndefined(); + await expect(App.findOneOrFail({ where: { id: application.id } })).rejects.toThrow(expect.any(Error)); }); it('should not be possible for non admin to delete an app', async () => { @@ -623,7 +623,7 @@ describe('apps controller', () => { expect(response.statusCode).toBe(403); - expect(await App.findOne({ where: { id: application.id } })).not.toBeUndefined(); + await expect(App.findOneOrFail({ where: { id: application.id } })).resolves; }); }); @@ -709,7 +709,7 @@ describe('apps controller', () => { }); await createApplicationVersion(app, application); - const allUserGroup = await getRepository(GroupPermission).findOne({ + const allUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'all_users', }, @@ -770,7 +770,7 @@ describe('apps controller', () => { }); const version = await createApplicationVersion(app, application); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -816,7 +816,7 @@ describe('apps controller', () => { expect(response.statusCode).toBe(201); - const v2 = await getManager().findOne(AppVersion, { + const v2 = await getManager().findOneOrFail(AppVersion, { where: { name: 'v2' }, }); expect(v2.definition).toEqual(v1.definition); @@ -985,13 +985,13 @@ describe('apps controller', () => { email: 'admin@tooljet.io', }); const application = await importAppFromTemplates(app, adminUserData.user, 'customer-dashboard'); - const dataSource = await getManager().findOne(DataSource, { + const dataSource = await getManager().findOneOrFail(DataSource, { where: { appId: application }, }); let dataSources = await getManager().find(DataSource); let dataQueries = await getManager().find(DataQuery); - const credential = await getManager().findOne(Credential, { + const credential = await getManager().findOneOrFail(Credential, { where: { id: dataSource.options['password']['credential_id'] }, }); credential.valueCiphertext = 'strongPassword'; @@ -1007,7 +1007,7 @@ describe('apps controller', () => { expect(response.statusCode).toBe(400); expect(response.body.message).toBe('More than one version found. Version to create from not specified.'); - const initialVersion = await getManager().findOne(AppVersion, { + const initialVersion = await getManager().findOneOrFail(AppVersion, { where: { appId: application.id, name: 'v0' }, }); @@ -1111,7 +1111,7 @@ describe('apps controller', () => { const version2 = await createApplicationVersion(app, application); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -1197,7 +1197,7 @@ describe('apps controller', () => { }); const version = await createApplicationVersion(app, application); - const allUserGroup = await getRepository(GroupPermission).findOne({ + const allUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'all_users', }, @@ -1257,7 +1257,9 @@ describe('apps controller', () => { const version = await createApplicationVersion(app, application); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ where: { group: 'developer' } }); + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ + where: { group: 'developer' }, + }); await createAppGroupPermission(app, application, developerUserGroup.id, { read: false, update: true, @@ -1380,7 +1382,7 @@ describe('apps controller', () => { }); await createApplicationVersion(app, application); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -1391,7 +1393,7 @@ describe('apps controller', () => { delete: false, }); // setup app permissions for viewer - const viewerUserGroup = await getRepository(GroupPermission).findOne({ + const viewerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'viewer', }, @@ -1475,7 +1477,7 @@ describe('apps controller', () => { slug: 'foo', }); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -1486,7 +1488,7 @@ describe('apps controller', () => { delete: false, }); // setup app permissions for viewer - const viewerUserGroup = await getRepository(GroupPermission).findOne({ + const viewerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'viewer', }, diff --git a/server/test/controllers/comment.e2e-spec.ts b/server/test/controllers/comment.e2e-spec.ts index b18331b6ff..b096078dd0 100644 --- a/server/test/controllers/comment.e2e-spec.ts +++ b/server/test/controllers/comment.e2e-spec.ts @@ -26,7 +26,7 @@ describe('comment controller', () => { }); it('should list all comments in a thread', async () => { - const userData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + const userData = await createUser(app, { email: 'admin@tooljet.io' }); const { user } = userData; @@ -42,7 +42,7 @@ describe('comment controller', () => { x: 100, y: 200, userId: userData.user.id, - organizationId: user.organization.id, + organizationId: user.organizationId, appVersionsId: version.id, }); diff --git a/server/test/controllers/data_queries.e2e-spec.ts b/server/test/controllers/data_queries.e2e-spec.ts index d7f123730f..2d2d890064 100644 --- a/server/test/controllers/data_queries.e2e-spec.ts +++ b/server/test/controllers/data_queries.e2e-spec.ts @@ -52,7 +52,7 @@ describe('data queries controller', () => { }); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -64,7 +64,7 @@ describe('data queries controller', () => { }); // setup app permissions for viewer - const viewerUserGroup = await getRepository(GroupPermission).findOne({ + const viewerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'viewer', }, @@ -142,7 +142,7 @@ describe('data queries controller', () => { }); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -229,7 +229,7 @@ describe('data queries controller', () => { groups: ['all_users', 'admin'], }); - const allUserGroup = await getManager().findOne(GroupPermission, { + const allUserGroup = await getManager().findOneOrFail(GroupPermission, { where: { group: 'all_users', organization: adminUserData.organization }, }); await getManager().update( @@ -239,7 +239,7 @@ describe('data queries controller', () => { ); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -337,7 +337,7 @@ describe('data queries controller', () => { }); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -440,6 +440,69 @@ describe('data queries controller', () => { expect(response.statusCode).toBe(403); }); + it('should be able to get queries sorted created wise', async () => { + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); + + const dataSource = await createDataSource(app, { + name: 'name', + kind: 'postgres', + application: application, + user: adminUserData.user, + }); + + const appVersion = await createApplicationVersion(app, application); + + const options = { + method: 'get', + url: null, + url_params: [['', '']], + headers: [['', '']], + body: [['', '']], + json_body: null, + body_toggle: false, + }; + + const createdQueries = []; + const totalQueries = 15; + + for (let i = 1; i <= totalQueries; i++) { + const queryParams = { + name: `restapi${i}`, + app_id: application.id, + data_source_id: dataSource.id, + kind: 'restapi', + options, + app_version_id: appVersion.id, + }; + + const response = await request(app.getHttpServer()) + .post(`/api/data_queries`) + .set('Authorization', authHeaderForUser(adminUserData.user)) + .send(queryParams); + + createdQueries.push(response.body); + } + + // Latest query should be on top + createdQueries.reverse(); + + const response = await request(app.getHttpServer()) + .get(`/api/data_queries?app_id=${application.id}&app_version_id=${appVersion.id}`) + .set('Authorization', authHeaderForUser(adminUserData.user)); + + expect(response.statusCode).toBe(200); + expect(response.body.data_queries.length).toBe(totalQueries); + expect(createdQueries).toMatchObject(response.body.data_queries); + }); + it('should be able to run queries of an app if the user belongs to the same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', @@ -473,7 +536,7 @@ describe('data queries controller', () => { }); // setup app permissions for developer - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -485,7 +548,7 @@ describe('data queries controller', () => { }); // setup app permissions for viewer - const viewerUserGroup = await getRepository(GroupPermission).findOne({ + const viewerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'viewer', }, diff --git a/server/test/controllers/data_sources.e2e-spec.ts b/server/test/controllers/data_sources.e2e-spec.ts index 43d4232630..bbcd8d20d9 100644 --- a/server/test/controllers/data_sources.e2e-spec.ts +++ b/server/test/controllers/data_sources.e2e-spec.ts @@ -51,7 +51,7 @@ describe('data sources controller', () => { }); const applicationVersion = await createApplicationVersion(app, application); - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -124,7 +124,7 @@ describe('data sources controller', () => { name: 'name', user: adminUserData.user, }); - const developerUserGroup = await getRepository(GroupPermission).findOne({ + const developerUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'developer', }, @@ -212,7 +212,7 @@ describe('data sources controller', () => { user: adminUserData.user, }); - const allUserGroup = await getRepository(GroupPermission).findOne({ + const allUserGroup = await getRepository(GroupPermission).findOneOrFail({ where: { group: 'all_users', organizationId: adminUserData.organization.id, diff --git a/server/test/controllers/folder_apps.e2e-spec.ts b/server/test/controllers/folder_apps.e2e-spec.ts index 036d47ebbe..eb5c64ddf7 100644 --- a/server/test/controllers/folder_apps.e2e-spec.ts +++ b/server/test/controllers/folder_apps.e2e-spec.ts @@ -40,6 +40,29 @@ describe('folder apps controller', () => { expect(folder_id).toBe(folder.id); }); + it('should not add an app to a folder more than once', async () => { + const { adminUser, app } = await setupOrganization(nestApp); + const manager = getManager(); + + // create a new folder + const folder = await manager.save( + manager.create(Folder, { name: 'folder', organizationId: adminUser.organizationId }) + ); + + await request(nestApp.getHttpServer()) + .post(`/api/folder_apps`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ folder_id: folder.id, app_id: app.id }); + + const response = await request(nestApp.getHttpServer()) + .post(`/api/folder_apps`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ folder_id: folder.id, app_id: app.id }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('App has been already added to the folder'); + }); + it('should remove an app from a folder', async () => { const { adminUser, app } = await setupOrganization(nestApp); const manager = getManager(); diff --git a/server/test/controllers/folders.e2e-spec.ts b/server/test/controllers/folders.e2e-spec.ts index cd1389b580..623ab826e4 100644 --- a/server/test/controllers/folders.e2e-spec.ts +++ b/server/test/controllers/folders.e2e-spec.ts @@ -34,7 +34,6 @@ describe('folders controller', () => { it('should list all folders in an organization', async () => { const adminUserData = await createUser(nestApp, { email: 'admin@tooljet.io', - role: 'admin', }); const { user } = adminUserData; @@ -66,7 +65,6 @@ describe('folders controller', () => { const anotherUserData = await createUser(nestApp, { email: 'admin@organization.com', - role: 'admin', }); await getManager().save(Folder, { name: 'Folder1', @@ -187,7 +185,6 @@ describe('folders controller', () => { const anotherUserData = await createUser(nestApp, { email: 'admin@organization.com', - role: 'admin', }); await getManager().save(Folder, { name: 'another org folder', @@ -229,7 +226,7 @@ describe('folders controller', () => { folderCreate: false, organization: newUserData.organization, }); - const group = await getManager().findOne(GroupPermission, { + const group = await getManager().findOneOrFail(GroupPermission, { where: { group: 'folder-handler' }, }); await createAppGroupPermission(nestApp, appInFolder, group.id, { @@ -278,7 +275,6 @@ describe('folders controller', () => { it('should create new folder in an organization', async () => { const adminUserData = await createUser(nestApp, { email: 'admin@tooljet.io', - role: 'admin', }); const { user } = adminUserData; diff --git a/server/test/controllers/group_permissions.e2e-spec.ts b/server/test/controllers/group_permissions.e2e-spec.ts index e63daa3c46..277b65e93a 100644 --- a/server/test/controllers/group_permissions.e2e-spec.ts +++ b/server/test/controllers/group_permissions.e2e-spec.ts @@ -251,22 +251,22 @@ describe('group permissions controller', () => { }); it('should not allow to remove users from admin group permission without any atleast one active admin', async () => { - const { - organization: { adminUser, defaultUser }, - } = await setupOrganizations(nestApp); + const { user, organization } = await createUser(nestApp, { + email: 'admin@tooljet.io', + }); const manager = getManager(); - const adminGroupPermission = await manager.findOne(GroupPermission, { + const adminGroupPermission = await manager.findOneOrFail(GroupPermission, { where: { group: 'admin', - organizationId: adminUser.organizationId, + organizationId: organization.id, }, }); const response = await request(nestApp.getHttpServer()) .put(`/api/group_permissions/${adminGroupPermission.id}`) - .set('Authorization', authHeaderForUser(adminUser)) - .send({ remove_users: [defaultUser.id] }); + .set('Authorization', authHeaderForUser(user)) + .send({ remove_users: [user.id] }); expect(response.statusCode).toBe(400); expect(response.body.message).toBe('Atleast one active admin is required.'); @@ -278,7 +278,7 @@ describe('group permissions controller', () => { } = await setupOrganizations(nestApp); const manager = getManager(); - const adminGroupPermission = await manager.findOne(GroupPermission, { + const adminGroupPermission = await manager.findOneOrFail(GroupPermission, { where: { group: 'all_users', organizationId: adminUser.organizationId, @@ -363,7 +363,7 @@ describe('group permissions controller', () => { } = await setupOrganizations(nestApp); const manager = getManager(); - const adminGroupPermission = await manager.findOne(GroupPermission, { + const adminGroupPermission = await manager.findOneOrFail(GroupPermission, { where: { group: 'admin', organizationId: organization.id, @@ -471,7 +471,7 @@ describe('group permissions controller', () => { } = await setupOrganizations(nestApp); const manager = getManager(); - const adminGroupPermission = await manager.findOne(GroupPermission, { + const adminGroupPermission = await manager.findOneOrFail(GroupPermission, { where: { group: 'admin', organizationId: organization.id, @@ -485,10 +485,11 @@ describe('group permissions controller', () => { expect(response.statusCode).toBe(200); const users = response.body.users; + const user = users[0]; expect(users).toHaveLength(1); - expect(user.organization_id).toBe(organization.id); + expect(user.default_organization_id).toBe(organization.id); expect(user.email).toBe('admin@tooljet.io'); }); }); @@ -506,21 +507,24 @@ describe('group permissions controller', () => { }); it('should allow admin to list users not in group permission', async () => { - const { - organization: { adminUser, organization }, - } = await setupOrganizations(nestApp); + const adminUser = await createUser(nestApp, { email: 'admin@tooljet.io' }); + const userone = await createUser(nestApp, { + email: 'userone@tooljet.io', + groups: ['all_users'], + organization: adminUser.organization, + }); const manager = getManager(); - const adminGroupPermission = await manager.findOne(GroupPermission, { + const adminGroupPermission = await manager.findOneOrFail(GroupPermission, { where: { group: 'admin', - organizationId: organization.id, + organizationId: adminUser.organization.id, }, }); const groupPermissionId = adminGroupPermission.id; const response = await request(nestApp.getHttpServer()) .get(`/api/group_permissions/${groupPermissionId}/addable_users`) - .set('Authorization', authHeaderForUser(adminUser)); + .set('Authorization', authHeaderForUser(adminUser.user)); expect(response.statusCode).toBe(200); @@ -528,8 +532,8 @@ describe('group permissions controller', () => { const user = users[0]; expect(users).toHaveLength(1); - expect(user.organization_id).toBe(organization.id); - expect(user.email).toBe('developer@tooljet.io'); + expect(user.default_organization_id).toBe(userone.organization.id); + expect(user.email).toBe('userone@tooljet.io'); }); }); @@ -552,14 +556,14 @@ describe('group permissions controller', () => { } = await setupOrganizations(nestApp); const manager = getManager(); - const groupPermission = await manager.findOne(GroupPermission, { + const groupPermission = await manager.findOneOrFail(GroupPermission, { where: { organizationId: organization.id, group: 'all_users', }, }); const groupPermissionId = groupPermission.id; - const appGroupPermission = await manager.findOne(AppGroupPermission, { + const appGroupPermission = await manager.findOneOrFail(AppGroupPermission, { where: { groupPermissionId, }, @@ -589,14 +593,14 @@ describe('group permissions controller', () => { } = await setupOrganizations(nestApp); const manager = getManager(); - const groupPermission = await manager.findOne(GroupPermission, { + const groupPermission = await manager.findOneOrFail(GroupPermission, { where: { organizationId: organization.id, group: 'all_users', }, }); const groupPermissionId = groupPermission.id; - const appGroupPermission = await manager.findOne(AppGroupPermission, { + const appGroupPermission = await manager.findOneOrFail(AppGroupPermission, { where: { groupPermissionId, }, @@ -644,7 +648,7 @@ describe('group permissions controller', () => { const anotherDefaultUserData = await createUser(nestApp, { email: 'another_developer@tooljet.io', groups: ['all_users'], - anotherOrganization, + organization: anotherOrganization, }); const anotherDefaultUser = anotherDefaultUserData.user; diff --git a/server/test/controllers/oauth.e2e-spec.ts b/server/test/controllers/oauth.e2e-spec.ts index db5b79ee04..1d043ee4d0 100644 --- a/server/test/controllers/oauth.e2e-spec.ts +++ b/server/test/controllers/oauth.e2e-spec.ts @@ -4,18 +4,23 @@ import { clearDB, createUser, createNestAppInstanceWithEnvMock } from '../test.h import { OAuth2Client } from 'google-auth-library'; import { mocked } from 'ts-jest/utils'; import got from 'got'; +import { Organization } from 'src/entities/organization.entity'; +import { Repository } from 'typeorm'; +import { SSOConfigs } from 'src/entities/sso_config.entity'; jest.mock('got'); const mockedGot = mocked(got); describe('oauth controller', () => { let app: INestApplication; + let ssoConfigsRepository: Repository; + let orgRepository: Repository; let mockConfig; beforeEach(async () => { await clearDB(); jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { - if (key === 'SSO_DISABLE_SIGNUP') { + if (key === 'MULTI_ORGANIZATION') { return 'false'; } else { return process.env[key]; @@ -25,6 +30,8 @@ describe('oauth controller', () => { beforeAll(async () => { ({ app, mockConfig } = await createNestAppInstanceWithEnvMock()); + ssoConfigsRepository = app.get('SSOConfigsRepository'); + orgRepository = app.get('OrganizationRepository'); }); afterEach(() => { @@ -32,426 +39,936 @@ describe('oauth controller', () => { jest.clearAllMocks(); }); - describe('sign in via Google OAuth', () => { - it('should return login info when the user does not exist', async () => { - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - - // Calling the createUser helper function to have an Organization created. This user is irrelevant for the test - await createUser(app, { email: 'anotherUser@tooljet.io', role: 'admin' }); - - const token = 'someStuff'; - - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'google' }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: process.env.SSO_GOOGLE_OAUTH2_CLIENT_ID, - }); - - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { email, first_name, last_name, admin, group_permissions, app_group_permissions } = response.body; - - expect(email).toEqual('ssoUser@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('User'); - expect(admin).toBeFalsy(); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - - it('should be forbid logging in when the user does not exist and signups are disabled', async () => { - jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { - if (key === 'SSO_DISABLE_SIGNUP') { - return 'true'; - } else { - return process.env[key]; - } - }); - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - - // Calling the createUser helper function to have an Organization created. This user is irrelevant for the test - await createUser(app, { email: 'anotherUser@tooljet.io', role: 'admin' }); - - const token = 'someStuff'; - - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'google' }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: process.env.SSO_GOOGLE_OAUTH2_CLIENT_ID, - }); - - expect(response.statusCode).toBe(401); - }); - - it('should be forbid logging in when the restricted domin is configured and domain not match', async () => { - jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { - if (key === 'SSO_RESTRICTED_DOMAIN') { - return 'tooljet.com,tooljet.in'; - } else { - return process.env[key]; - } - }); - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - - // Calling the createUser helper function to have an Organization created. This user is irrelevant for the test - await createUser(app, { email: 'anotherUser@tooljet.io', role: 'admin' }); - - const token = 'someStuff'; - - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'google' }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: process.env.SSO_GOOGLE_OAUTH2_CLIENT_ID, - }); - - expect(response.statusCode).toBe(401); - }); - - it('should be success when the restricted domin is configured and domain matches', async () => { - jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { - if (key === 'SSO_RESTRICTED_DOMAIN') { - return 'tooljet.com,tooljet.io'; - } else { - return process.env[key]; - } - }); - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'SSO User', - hd: 'tooljet.io', - }), - })); - - // Calling the createUser helper function to have an Organization created. This user is irrelevant for the test - await createUser(app, { email: 'anotherUser@tooljet.io', role: 'admin' }); - - const token = 'someStuff'; - - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'google' }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: process.env.SSO_GOOGLE_OAUTH2_CLIENT_ID, - }); - - expect(response.statusCode).toBe(201); - }); - - it('should return login info when the user exists', async () => { - const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); - googleVerifyMock.mockImplementation(() => ({ - getPayload: () => ({ - sub: 'someSSOId', - email: 'ssoUser@tooljet.io', - name: 'New name', - hd: 'tooljet.io', - }), - })); - - await createUser(app, { - email: 'ssoUser@tooljet.io', - role: 'developer', - ssoId: 'someSSOId', - firstName: 'Existing', - lastName: 'Name', - groups: ['all_users', 'admin'], - }); - - const token = 'someStuff'; - - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'google' }); - - expect(googleVerifyMock).toHaveBeenCalledWith({ - idToken: token, - audience: process.env.SSO_GOOGLE_OAUTH2_CLIENT_ID, - }); - - expect(response.statusCode).toBe(201); - expect(new Set(Object.keys(response.body))).toEqual( - new Set([ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'group_permissions', - 'app_group_permissions', - ]) - ); - - const { email, first_name, last_name, admin, group_permissions, app_group_permissions } = response.body; - - expect(email).toEqual('ssoUser@tooljet.io'); - expect(first_name).toEqual('Existing'); - expect(last_name).toEqual('Name'); - expect(admin).toBeTruthy(); - expect(group_permissions).toHaveLength(2); - expect(group_permissions.map((p) => p.group).sort()).toEqual(['all_users', 'admin'].sort()); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'folder_create', - 'updated_at', - 'created_at', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); - }); - }); - - describe('sign in via Git OAuth', () => { - it('should return login info when the user does not exist', async () => { - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; + describe('SSO Login', () => { + let current_organization: Organization; + beforeEach(async () => { + const { organization } = await createUser(app, { + email: 'anotherUser@tooljet.io', + ssoConfigs: [ + { sso: 'google', enabled: true, configs: { clientId: 'client-id' } }, + { + sso: 'git', + enabled: true, + configs: { clientId: 'client-id' }, }, - }; + ], + enableSignUp: true, }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO UserGit', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; - }); - - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); - const token = 'some-token'; - - // Calling the createUser helper function to have an Organization created. This user is irrelevant for the test - await createUser(app, { email: 'anotherUser@tooljet.io', role: 'admin' }); - - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'git' }); - - expect(response.statusCode).toBe(201); - expect(Object.keys(response.body).sort()).toEqual( - [ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'group_permissions', - 'app_group_permissions', - ].sort() - ); - - const { email, first_name, last_name, admin, group_permissions, app_group_permissions } = response.body; - - expect(email).toEqual('ssoUserGit@tooljet.io'); - expect(first_name).toEqual('SSO'); - expect(last_name).toEqual('UserGit'); - expect(admin).toBeFalsy(); - expect(group_permissions).toHaveLength(1); - expect(group_permissions[0].group).toEqual('all_users'); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'updated_at', - 'created_at', - 'folder_create', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); + current_organization = organization; }); - it('should be forbid logging in when the user does not exist and signups are disabled', async () => { - jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { - if (key === 'SSO_DISABLE_SIGNUP') { - return 'true'; - } else { - return process.env[key]; - } + describe('sign in via Google OAuth', () => { + let sso_configs; + const token = 'some-Token'; + beforeEach(() => { + sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'google'); }); - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; - }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'SSO UserGit', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; + it('should return 401 if google sign in is disabled', async () => { + await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); + it('should return 401 when the user does not exist and sign up is disabled', async () => { + await orgRepository.update(current_organization.id, { enableSignUp: false }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); - // Calling the createUser helper function to have an Organization created. This user is irrelevant for the test - await createUser(app, { email: 'anotherUser@tooljet.io', role: 'admin' }); + it('should return 401 when the user does not exist domain mismatch', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljett.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); - const token = 'someStuff'; + it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'git' }); + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); - expect(response.statusCode).toBe(401); + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('User'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + + it('should return login info when the user does not exist and sign up is enabled', async () => { + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('User'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'ssoUser@tooljet.io', + name: '', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUser@tooljet.io'); + expect(first_name).toEqual('ssoUser'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user exist', async () => { + await createUser(app, { + firstName: 'SSO', + lastName: 'userExist', + email: 'anotherUser1@tooljet.io', + groups: ['all_users'], + organization: current_organization, + status: 'active', + }); + const googleVerifyMock = jest.spyOn(OAuth2Client.prototype, 'verifyIdToken'); + googleVerifyMock.mockImplementation(() => ({ + getPayload: () => ({ + sub: 'someSSOId', + email: 'anotherUser1@tooljet.io', + name: 'SSO User', + hd: 'tooljet.io', + }), + })); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(googleVerifyMock).toHaveBeenCalledWith({ + idToken: token, + audience: sso_configs.configs.clientId, + }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('anotherUser1@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('userExist'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); }); - - it('should return login info when the user exists', async () => { - const gitAuthResponse = jest.fn(); - gitAuthResponse.mockImplementation(() => { - return { - json: () => { - return { - access_token: 'some-access-token', - scope: 'scope', - token_type: 'bearer', - }; - }, - }; + describe('sign in via Git OAuth', () => { + let sso_configs; + const token = 'some-Token'; + beforeEach(() => { + sso_configs = current_organization.ssoConfigs.find((conf) => conf.sso === 'git'); }); - const gitGetUserResponse = jest.fn(); - gitGetUserResponse.mockImplementation(() => { - return { - json: () => { - return { - name: 'Existing Name', - email: 'ssoUserGit@tooljet.io', - }; - }, - }; + it('should return 401 if git sign in is disabled', async () => { + await ssoConfigsRepository.update(sso_configs.id, { enabled: false }); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - mockedGot.mockImplementationOnce(gitAuthResponse); - mockedGot.mockImplementationOnce(gitGetUserResponse); + it('should return 401 when the user does not exist and sign up is disabled', async () => { + await orgRepository.update(current_organization.id, { enableSignUp: false }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); - await createUser(app, { - email: 'ssoUserGit@tooljet.io', - role: 'developer', - ssoId: 'someSSOId', - firstName: 'Existing', - lastName: 'Name', - groups: ['all_users', 'admin'], + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); }); - const token = 'someStuff'; + it('should return 401 when the user does not exist domain mismatch', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljett.io', + }; + }, + }; + }); - const response = await request(app.getHttpServer()).post('/api/oauth/sign-in').send({ token, origin: 'git' }); + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); - expect(response.statusCode).toBe(201); - expect(new Set(Object.keys(response.body))).toEqual( - new Set([ - 'id', - 'email', - 'first_name', - 'last_name', - 'auth_token', - 'admin', - 'group_permissions', - 'app_group_permissions', - ]) - ); + await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }) + .expect(401); + }); - const { email, first_name, last_name, admin, group_permissions, app_group_permissions } = response.body; + it('should return login info when the user does not exist and domain matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { domain: 'tooljet.io,tooljet.com' }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); - expect(email).toEqual('ssoUserGit@tooljet.io'); - expect(first_name).toEqual('Existing'); - expect(last_name).toEqual('Name'); - expect(admin).toBeTruthy(); - expect(group_permissions).toHaveLength(2); - expect(group_permissions.map((p) => p.group).sort()).toEqual(['all_users', 'admin'].sort()); - expect(Object.keys(group_permissions[0]).sort()).toEqual( - [ - 'id', - 'organization_id', - 'group', - 'app_create', - 'app_delete', - 'folder_create', - 'updated_at', - 'created_at', - ].sort() - ); - expect(app_group_permissions).toHaveLength(0); + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + + it('should return login info when the user does not exist and domain includes spance matches and sign up is enabled', async () => { + await orgRepository.update(current_organization.id, { + domain: ' tooljet.io , tooljet.com, , , gmail.com', + }); + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + + it('should return login info when the user does not exist and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO UserGit', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('UserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and name not available and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: '', + email: 'ssoUserGit@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('ssoUserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user does not exist and email id not available and sign up is enabled', async () => { + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: '', + email: '', + }; + }, + }; + }); + const gitGetUserEmailResponse = jest.fn(); + gitGetUserEmailResponse.mockImplementation(() => { + return { + json: () => { + return [ + { + email: 'ssoUserGit@tooljet.io', + primary: true, + verified: true, + }, + { + email: 'ssoUserGit2@tooljet.io', + primary: false, + verified: true, + }, + ]; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + mockedGot.mockImplementationOnce(gitGetUserEmailResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { email, first_name, admin, group_permissions, app_group_permissions, organization_id, organization } = + response.body; + + expect(email).toEqual('ssoUserGit@tooljet.io'); + expect(first_name).toEqual('ssoUserGit'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); + it('should return login info when the user exist', async () => { + await createUser(app, { + firstName: 'SSO', + lastName: 'userExist', + email: 'anotherUser1@tooljet.io', + groups: ['all_users'], + organization: current_organization, + status: 'active', + }); + + const gitAuthResponse = jest.fn(); + gitAuthResponse.mockImplementation(() => { + return { + json: () => { + return { + access_token: 'some-access-token', + scope: 'scope', + token_type: 'bearer', + }; + }, + }; + }); + const gitGetUserResponse = jest.fn(); + gitGetUserResponse.mockImplementation(() => { + return { + json: () => { + return { + name: 'SSO userExist', + email: 'anotherUser1@tooljet.io', + }; + }, + }; + }); + + mockedGot.mockImplementationOnce(gitAuthResponse); + mockedGot.mockImplementationOnce(gitGetUserResponse); + + const response = await request(app.getHttpServer()) + .post('/api/oauth/sign-in/' + sso_configs.id) + .send({ token }); + + expect(response.statusCode).toBe(201); + expect(Object.keys(response.body).sort()).toEqual( + [ + 'id', + 'email', + 'first_name', + 'last_name', + 'auth_token', + 'admin', + 'organization_id', + 'organization', + 'group_permissions', + 'app_group_permissions', + ].sort() + ); + + const { + email, + first_name, + last_name, + admin, + group_permissions, + app_group_permissions, + organization_id, + organization, + } = response.body; + + expect(email).toEqual('anotherUser1@tooljet.io'); + expect(first_name).toEqual('SSO'); + expect(last_name).toEqual('userExist'); + expect(admin).toBeFalsy(); + expect(organization_id).toBe(current_organization.id); + expect(organization).toBe(current_organization.name); + expect(group_permissions).toHaveLength(1); + expect(group_permissions[0].group).toEqual('all_users'); + expect(Object.keys(group_permissions[0]).sort()).toEqual( + [ + 'id', + 'organization_id', + 'group', + 'app_create', + 'app_delete', + 'updated_at', + 'created_at', + 'folder_create', + ].sort() + ); + expect(app_group_permissions).toHaveLength(0); + }); }); }); diff --git a/server/test/controllers/organization_users.e2e-spec.ts b/server/test/controllers/organization_users.e2e-spec.ts index dbf87eeb27..46ade25d99 100644 --- a/server/test/controllers/organization_users.e2e-spec.ts +++ b/server/test/controllers/organization_users.e2e-spec.ts @@ -44,19 +44,19 @@ describe('organization users controller', () => { await request(app.getHttpServer()) .post(`/api/organization_users/`) .set('Authorization', authHeaderForUser(adminUserData.user)) - .send({ email: 'test@tooljet.io', groups: ['Viewer', 'all_users'] }) + .send({ email: 'test@tooljet.io' }) .expect(201); await request(app.getHttpServer()) .post(`/api/organization_users/`) .set('Authorization', authHeaderForUser(developerUserData.user)) - .send({ email: 'test2@tooljet.io', groups: ['Viewer', 'all_users'] }) + .send({ email: 'test2@tooljet.io' }) .expect(403); await request(app.getHttpServer()) .post(`/api/organization_users/`) .set('Authorization', authHeaderForUser(viewerUserData.user)) - .send({ email: 'test3@tooljet.io', groups: ['Viewer', 'all_users'] }) + .send({ email: 'test3@tooljet.io' }) .expect(403); }); @@ -103,7 +103,6 @@ describe('organization users controller', () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', groups: ['admin', 'all_users'], - status: 'active', }); const organization = adminUserData.organization; const developerUserData = await createUser(app, { @@ -115,6 +114,7 @@ describe('organization users controller', () => { email: 'viewer@tooljet.io', groups: ['viewer', 'all_users'], organization, + status: 'invited', }); await request(app.getHttpServer()) @@ -156,8 +156,6 @@ describe('organization users controller', () => { const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', status: 'archived', - invitationToken: 'old-token', - password: 'old-password', groups: ['viewer', 'all_users'], organization, }); @@ -186,7 +184,7 @@ describe('organization users controller', () => { await viewerUserData.orgUser.reload(); await viewerUserData.user.reload(); expect(viewerUserData.orgUser.status).toBe('invited'); - expect(viewerUserData.user.invitationToken).not.toBe('old-token'); + expect(viewerUserData.user.invitationToken).not.toBe(''); expect(viewerUserData.user.password).not.toBe('old-password'); }); diff --git a/server/test/controllers/organizations.e2e-spec.ts b/server/test/controllers/organizations.e2e-spec.ts index cfd41a7e23..98e9f842cd 100644 --- a/server/test/controllers/organizations.e2e-spec.ts +++ b/server/test/controllers/organizations.e2e-spec.ts @@ -1,44 +1,345 @@ import * as request from 'supertest'; import { INestApplication } from '@nestjs/common'; -import { authHeaderForUser, clearDB, createUser, createNestAppInstance } from '../test.helper'; +import { authHeaderForUser, clearDB, createUser, createNestAppInstanceWithEnvMock } from '../test.helper'; +import { Repository } from 'typeorm'; +import { SSOConfigs } from 'src/entities/sso_config.entity'; +import { User } from 'src/entities/user.entity'; describe('organizations controller', () => { let app: INestApplication; + let ssoConfigsRepository: Repository; + let userRepository: Repository; + let mockConfig; beforeEach(async () => { await clearDB(); + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'false'; + default: + return process.env[key]; + } + }); }); beforeAll(async () => { - app = await createNestAppInstance(); + ({ app, mockConfig } = await createNestAppInstanceWithEnvMock()); + ssoConfigsRepository = app.get('SSOConfigsRepository'); + userRepository = app.get('UserRepository'); }); - it('should allow only authenticated users to list org users', async () => { - await request(app.getHttpServer()).get('/api/organizations/users').expect(401); + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); }); - it('should list organization users', async () => { - const userData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { organization, user, orgUser } = userData; + describe('list organization users', () => { + it('should allow only authenticated users to list org users', async () => { + await request(app.getHttpServer()).get('/api/organizations/users').expect(401); + }); - const response = await request(app.getHttpServer()) - .get('/api/organizations/users') - .set('Authorization', authHeaderForUser(user)); + it('should list organization users', async () => { + const userData = await createUser(app, { email: 'admin@tooljet.io' }); + const { user, orgUser } = userData; - expect(response.statusCode).toBe(200); - expect(response.body.users.length).toBe(1); + const response = await request(app.getHttpServer()) + .get('/api/organizations/users') + .set('Authorization', authHeaderForUser(user)); - await orgUser.reload(); + expect(response.statusCode).toBe(200); + expect(response.body.users.length).toBe(1); - expect(response.body.users[0]).toStrictEqual({ - email: user.email, - first_name: user.firstName, - id: orgUser.id, - last_name: user.lastName, - name: `${user.firstName} ${user.lastName}`, - role: orgUser.role, - status: orgUser.status, + await orgUser.reload(); + + expect(response.body.users[0]).toStrictEqual({ + email: user.email, + first_name: user.firstName, + id: orgUser.id, + last_name: user.lastName, + name: `${user.firstName} ${user.lastName}`, + role: orgUser.role, + status: orgUser.status, + }); + }); + + describe('create organization', () => { + it('should allow only authenticated users to create organization', async () => { + await request(app.getHttpServer()).post('/api/organizations').send({ name: 'My organization' }).expect(401); + }); + it('should create new organization if multi organization supported', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + const { user, organization } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const response = await request(app.getHttpServer()) + .post('/api/organizations') + .send({ name: 'My organization' }) + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(201); + expect(response.body.organization_id).not.toBe(organization.id); + expect(response.body.organization).toBe('My organization'); + expect(response.body.admin).toBeTruthy(); + + const newUser = await userRepository.findOneOrFail({ where: { id: user.id } }); + expect(newUser.defaultOrganizationId).toBe(response.body.organization_id); + }); + + it('should throw error if name is empty', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + const { user } = await createUser(app, { email: 'admin@tooljet.io' }); + const response = await request(app.getHttpServer()) + .post('/api/organizations') + .send({ name: '' }) + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(400); + }); + + it('should not create new organization if multi organization not supported', async () => { + const { user } = await createUser(app, { email: 'admin@tooljet.io' }); + await request(app.getHttpServer()) + .post('/api/organizations') + .send({ name: 'My organization' }) + .set('Authorization', authHeaderForUser(user)) + .expect(403); + }); + + it('should create new organization if multi organization supported and user logged in via SSO', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + const { user, organization } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const response = await request(app.getHttpServer()) + .post('/api/organizations') + .send({ name: 'My organization' }) + .set('Authorization', authHeaderForUser(user, null, false)); + + expect(response.statusCode).toBe(201); + expect(response.body.organization_id).not.toBe(organization.id); + expect(response.body.organization).toBe('My organization'); + expect(response.body.admin).toBeTruthy(); + }); + }); + describe('update organization', () => { + it('should change organization params if changes are done by admin', async () => { + const { user, organization } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const response = await request(app.getHttpServer()) + .patch('/api/organizations') + .send({ name: 'new name', domain: 'tooljet.io', enableSignUp: true }) + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(200); + await organization.reload(); + expect(organization.name).toBe('new name'); + expect(organization.domain).toBe('tooljet.io'); + expect(organization.enableSignUp).toBeTruthy(); + }); + + it('should not change organization params if changes are not done by admin', async () => { + const { organization } = await createUser(app, { email: 'admin@tooljet.io' }); + const developerUserData = await createUser(app, { + email: 'developer@tooljet.io', + groups: ['all_users'], + organization, + }); + const response = await request(app.getHttpServer()) + .patch('/api/organizations') + .send({ name: 'new name', domain: 'tooljet.io', enableSignUp: true }) + .set('Authorization', authHeaderForUser(developerUserData.user)); + + expect(response.statusCode).toBe(403); + }); + }); + describe('update organization configs', () => { + it('should change organization configs if changes are done by admin', async () => { + const { user } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const response = await request(app.getHttpServer()) + .patch('/api/organizations/configs') + .send({ type: 'git', configs: { clientId: 'client-id', clientSecret: 'client-secret' }, enabled: true }) + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(200); + const ssoConfigs = await ssoConfigsRepository.findOneOrFail({ where: { id: response.body.id } }); + expect(ssoConfigs.sso).toBe('git'); + expect(ssoConfigs.enabled).toBeTruthy(); + expect(ssoConfigs.configs.clientId).toBe('client-id'); + expect(ssoConfigs.configs['clientSecret']).not.toBe('client-secret'); + }); + + it('should not change organization configs if changes are not done by admin', async () => { + const { user } = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users'], + }); + const response = await request(app.getHttpServer()) + .patch('/api/organizations/configs') + .send({ type: 'git', configs: { clientId: 'client-id', clientSecret: 'client-secret' }, enabled: true }) + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(403); + }); + }); + describe('get organization configs', () => { + it('should get organization details if requested by admin', async () => { + const { user, organization } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const response = await request(app.getHttpServer()) + .patch('/api/organizations/configs') + .send({ type: 'git', configs: { clientId: 'client-id', clientSecret: 'client-secret' }, enabled: true }) + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(200); + + const getResponse = await request(app.getHttpServer()) + .get('/api/organizations/configs') + .set('Authorization', authHeaderForUser(user)); + + expect(getResponse.statusCode).toBe(200); + + expect(getResponse.body.organization_details.id).toBe(organization.id); + expect(getResponse.body.organization_details.name).toBe(organization.name); + expect(getResponse.body.organization_details.sso_configs.length).toBe(2); + expect(getResponse.body.organization_details.sso_configs.find((ob) => ob.sso === 'form').organization_id).toBe( + organization.id + ); + expect(getResponse.body.organization_details.sso_configs.find((ob) => ob.sso === 'git').enabled).toBeTruthy(); + expect(getResponse.body.organization_details.sso_configs.find((ob) => ob.sso === 'git').configs).toEqual({ + client_id: 'client-id', + client_secret: 'client-secret', + }); + }); + + it('should not get organization configs if request not done by admin', async () => { + const { user } = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users'], + }); + const response = await request(app.getHttpServer()) + .get('/api/organizations/configs') + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(403); + }); + }); + + describe('get public organization configs', () => { + it('should get organization details for all users for single organization', async () => { + const { user } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const response = await request(app.getHttpServer()) + .patch('/api/organizations/configs') + .send({ type: 'git', configs: { clientId: 'client-id', clientSecret: 'client-secret' }, enabled: true }) + .set('Authorization', authHeaderForUser(user)); + + const authGetResponse = await request(app.getHttpServer()) + .get('/api/organizations/configs') + .set('Authorization', authHeaderForUser(user)); + + expect(authGetResponse.statusCode).toBe(200); + + expect(response.statusCode).toBe(200); + + const getResponse = await request(app.getHttpServer()).get('/api/organizations/public-configs'); + + expect(getResponse.statusCode).toBe(200); + expect(getResponse.body).toEqual({ + sso_configs: { + name: 'Test Organization', + form: { + config_id: authGetResponse.body.organization_details.sso_configs.find((ob) => ob.sso === 'form').id, + sso: 'form', + configs: {}, + enabled: true, + }, + git: { + config_id: authGetResponse.body.organization_details.sso_configs.find((ob) => ob.sso === 'git').id, + sso: 'git', + configs: { client_id: 'client-id', client_secret: '' }, + enabled: true, + }, + }, + }); + }); + + it('should get organization specific details for all users for multiple organization deployment', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + const { user, organization } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const response = await request(app.getHttpServer()) + .patch('/api/organizations/configs') + .send({ type: 'git', configs: { clientId: 'client-id', clientSecret: 'client-secret' }, enabled: true }) + .set('Authorization', authHeaderForUser(user)); + + expect(response.statusCode).toBe(200); + + const getResponse = await request(app.getHttpServer()).get( + `/api/organizations/${organization.id}/public-configs` + ); + + expect(getResponse.statusCode).toBe(200); + + const authGetResponse = await request(app.getHttpServer()) + .get('/api/organizations/configs') + .set('Authorization', authHeaderForUser(user)); + + expect(authGetResponse.statusCode).toBe(200); + + expect(getResponse.statusCode).toBe(200); + expect(getResponse.body).toEqual({ + sso_configs: { + name: 'Test Organization', + form: { + config_id: authGetResponse.body.organization_details.sso_configs.find((ob) => ob.sso === 'form').id, + sso: 'form', + configs: {}, + enabled: true, + }, + git: { + config_id: authGetResponse.body.organization_details.sso_configs.find((ob) => ob.sso === 'git').id, + sso: 'git', + configs: { client_id: 'client-id', client_secret: '' }, + enabled: true, + }, + }, + }); + }); }); }); diff --git a/server/test/controllers/thread.e2e-spec.ts b/server/test/controllers/thread.e2e-spec.ts index 9c82b99b85..f87b3214bd 100644 --- a/server/test/controllers/thread.e2e-spec.ts +++ b/server/test/controllers/thread.e2e-spec.ts @@ -28,7 +28,6 @@ describe('thread controller', () => { it('should list all threads in an application', async () => { const userData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', }); const application = await createApplication(app, { name: 'App', @@ -41,7 +40,7 @@ describe('thread controller', () => { x: 100, y: 200, userId: userData.user.id, - organizationId: user.organization.id, + organizationId: user.organizationId, appVersionsId: version.id, }); diff --git a/server/test/controllers/users.e2e-spec.ts b/server/test/controllers/users.e2e-spec.ts index bc2d2778e6..975ad27312 100644 --- a/server/test/controllers/users.e2e-spec.ts +++ b/server/test/controllers/users.e2e-spec.ts @@ -1,26 +1,36 @@ import * as request from 'supertest'; import { INestApplication } from '@nestjs/common'; -import { authHeaderForUser, clearDB, createUser, createNestAppInstance } from '../test.helper'; +import { authHeaderForUser, clearDB, createUser, createNestAppInstanceWithEnvMock } from '../test.helper'; import { getManager } from 'typeorm'; import { User } from 'src/entities/user.entity'; +import { v4 as uuidv4 } from 'uuid'; +import { OrganizationUser } from 'src/entities/organization_user.entity'; describe('users controller', () => { let app: INestApplication; + let mockConfig; beforeEach(async () => { await clearDB(); + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'DISABLE_SIGNUPS': + return 'false'; + case 'MULTI_ORGANIZATION': + return 'false'; + default: + return process.env[key]; + } + }); }); beforeAll(async () => { - app = await createNestAppInstance(); + ({ app, mockConfig } = await createNestAppInstanceWithEnvMock()); }); describe('PATCH /api/users/change_password', () => { it('should allow users to update their password', async () => { - const userData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - }); + const userData = await createUser(app, { email: 'admin@tooljet.io' }); const { user } = userData; const oldPassword = user.password; @@ -31,18 +41,12 @@ describe('users controller', () => { .send({ currentPassword: 'password', newPassword: 'new password' }); expect(response.statusCode).toBe(200); - - const updatedUser = await getManager().findOne(User, { - where: { email: user.email }, - }); + const updatedUser = await getManager().findOneOrFail(User, { where: { email: user.email } }); expect(updatedUser.password).not.toEqual(oldPassword); }); it('should not allow users to update their password if entered current password is wrong', async () => { - const userData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - }); + const userData = await createUser(app, { email: 'admin@tooljet.io' }); const { user } = userData; const oldPassword = user.password; @@ -57,19 +61,14 @@ describe('users controller', () => { expect(response.statusCode).toBe(403); - const updatedUser = await getManager().findOne(User, { - where: { email: user.email }, - }); + const updatedUser = await getManager().findOneOrFail(User, { where: { email: user.email } }); expect(updatedUser.password).toEqual(oldPassword); }); }); describe('PATCH /api/users/update', () => { it('should allow users to update their firstName, lastName and password', async () => { - const userData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - }); + const userData = await createUser(app, { email: 'admin@tooljet.io' }); const { user } = userData; const [firstName, lastName] = ['Daenerys', 'Targaryen']; @@ -81,57 +80,67 @@ describe('users controller', () => { expect(response.statusCode).toBe(200); - const updatedUser = await getManager().findOne(User, { - where: { email: user.email }, - }); + const updatedUser = await getManager().findOneOrFail(User, { where: { email: user.email } }); expect(updatedUser.firstName).toEqual(firstName); expect(updatedUser.lastName).toEqual(lastName); }); }); describe('POST /api/users/set_password_from_token', () => { - it('should allow users to set password from token', async () => { - const adminUserData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', + it('should allow users to setup account after sign up using multi organization', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'DISABLE_SIGNUPS': + return 'false'; + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } }); - const organization = adminUserData.organization; - const anotherUserData = await createUser(app, { - email: 'developer@tooljet.io', - groups: ['all_users'], - invitationToken: 'token', - organization, + const invitationToken = uuidv4(); + const userData = await createUser(app, { + email: 'signup@tooljet.io', + invitationToken, + status: 'invited', }); + const { user, organization } = userData; const response = await request(app.getHttpServer()).post('/api/users/set_password_from_token').send({ - first_name: 'Khal', - last_name: 'Drogo', - token: 'token', - organization: 'Dothraki Pvt Limited', - password: 'Khaleesi', - new_signup: true, + first_name: 'signupuser', + last_name: 'user', + organization: 'org1', + password: uuidv4(), + token: invitationToken, + role: 'developer', }); expect(response.statusCode).toBe(201); - const updatedUser = await getManager().findOne(User, { - where: { email: anotherUserData.user.email }, - }); - expect(updatedUser.firstName).toEqual('Khal'); - expect(updatedUser.lastName).toEqual('Drogo'); + const updatedUser = await getManager().findOneOrFail(User, { where: { email: user.email } }); + expect(updatedUser.firstName).toEqual('signupuser'); + expect(updatedUser.lastName).toEqual('user'); + expect(updatedUser.defaultOrganizationId).toEqual(organization.id); + const organizationUser = await getManager().findOneOrFail(OrganizationUser, { where: { userId: user.id } }); + expect(organizationUser.status).toEqual('active'); }); - it('should return error if required params are not present', async () => { - const adminUserData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', + it('should return error if required params are not present - multi organization', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'DISABLE_SIGNUPS': + return 'false'; + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } }); - const organization = adminUserData.organization; + const invitationToken = uuidv4(); await createUser(app, { - email: 'developer@tooljet.io', - groups: ['all_users'], - invitationToken: 'token', - organization, + email: 'signup@tooljet.io', + invitationToken, + status: 'invited', }); const response = await request(app.getHttpServer()).post('/api/users/set_password_from_token'); @@ -144,6 +153,225 @@ describe('users controller', () => { 'token must be a string', ]); }); + + it('should not allow users to setup account for single organization', async () => { + const invitationToken = uuidv4(); + await createUser(app, { + email: 'signup@tooljet.io', + invitationToken, + status: 'invited', + }); + + const response = await request(app.getHttpServer()).post('/api/users/set_password_from_token').send({ + first_name: 'signupuser', + last_name: 'user', + organization: 'org1', + password: uuidv4(), + token: invitationToken, + role: 'developer', + }); + + expect(response.statusCode).toBe(403); + }); + + it('should not allow users to setup account for multi organization and sign up disabled', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'DISABLE_SIGNUPS': + return 'true'; + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + const invitationToken = uuidv4(); + await createUser(app, { + email: 'signup@tooljet.io', + invitationToken, + status: 'invited', + }); + + const response = await request(app.getHttpServer()).post('/api/users/set_password_from_token').send({ + first_name: 'signupuser', + last_name: 'user', + organization: 'org1', + password: uuidv4(), + token: invitationToken, + role: 'developer', + }); + + expect(response.statusCode).toBe(403); + }); + + it('should allow users to setup account if already invited to an organization but not activated', async () => { + const org = ( + await createUser(app, { + email: 'admin@tooljet.io', + }) + ).organization; + const invitedUser = await createUser(app, { + email: 'invited@tooljet.io', + status: 'invited', + organization: org, + }); + + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + + const signUpResponse = await request(app.getHttpServer()) + .post('/api/signup') + .send({ email: 'invited@tooljet.io' }); + + expect(signUpResponse.statusCode).toBe(201); + + const invitedUserDetails = await getManager().findOneOrFail(User, { where: { email: invitedUser.user.email } }); + + expect(invitedUserDetails.defaultOrganizationId).not.toBe(org.id); + + const response = await request(app.getHttpServer()).post('/api/users/set_password_from_token').send({ + first_name: 'signupuser', + last_name: 'user', + organization: 'org1', + password: uuidv4(), + token: invitedUserDetails.invitationToken, + role: 'developer', + }); + + expect(response.statusCode).toBe(201); + const updatedUser = await getManager().findOneOrFail(User, { where: { email: invitedUser.user.email } }); + expect(updatedUser.firstName).toEqual('signupuser'); + expect(updatedUser.lastName).toEqual('user'); + expect(updatedUser.defaultOrganizationId).not.toBe(org.id); + const organizationUser = await getManager().findOneOrFail(OrganizationUser, { + where: { userId: invitedUser.user.id, organizationId: org.id }, + }); + const defaultOrganizationUser = await getManager().findOneOrFail(OrganizationUser, { + where: { userId: invitedUser.user.id, organizationId: invitedUserDetails.defaultOrganizationId }, + }); + expect(organizationUser.status).toEqual('invited'); + expect(defaultOrganizationUser.status).toEqual('active'); + }); + + it('should not allow users to setup account if already invited to an organization and activated account through invite link after sign up', async () => { + const { organization: org } = await createUser(app, { + email: 'admin@tooljet.io', + }); + const invitedUser = await createUser(app, { + email: 'invited@tooljet.io', + status: 'invited', + organization: org, + }); + + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + + const signUpResponse = await request(app.getHttpServer()) + .post('/api/signup') + .send({ email: 'invited@tooljet.io' }); + + expect(signUpResponse.statusCode).toBe(201); + + const invitedUserDetails = await getManager().findOneOrFail(User, { where: { email: invitedUser.user.email } }); + + expect(invitedUserDetails.defaultOrganizationId).not.toBe(org.id); + + const acceptInviteResponse = await request(app.getHttpServer()).post('/api/users/accept-invite').send({ + token: invitedUser.orgUser.invitationToken, + password: 'new-password', + }); + + expect(acceptInviteResponse.statusCode).toBe(201); + + const organizationUser = await getManager().findOneOrFail(OrganizationUser, { + where: { userId: invitedUser.user.id, organizationId: org.id }, + }); + const defaultOrganizationUser = await getManager().findOneOrFail(OrganizationUser, { + where: { userId: invitedUser.user.id, organizationId: invitedUserDetails.defaultOrganizationId }, + }); + expect(organizationUser.status).toEqual('active'); + expect(defaultOrganizationUser.status).toEqual('active'); + + const updatedUser = await getManager().findOneOrFail(User, { where: { email: invitedUser.user.email } }); + expect(updatedUser.defaultOrganizationId).toBe(defaultOrganizationUser.organizationId); + + const response = await request(app.getHttpServer()).post('/api/users/set_password_from_token').send({ + first_name: 'signupuser', + last_name: 'user', + organization: 'org1', + password: uuidv4(), + token: invitedUserDetails.invitationToken, + role: 'developer', + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe('POST /api/users/accept-invite', () => { + it('should allow users to accept invitation when multi organization is enabled', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'true'; + default: + return process.env[key]; + } + }); + const userData = await createUser(app, { + email: 'organizationUser@tooljet.io', + status: 'invited', + }); + const { user, orgUser } = userData; + + const response = await request(app.getHttpServer()).post('/api/users/accept-invite').send({ + token: orgUser.invitationToken, + password: uuidv4(), + }); + + expect(response.statusCode).toBe(201); + + const organizationUser = await getManager().findOneOrFail(OrganizationUser, { where: { userId: user.id } }); + expect(organizationUser.status).toEqual('active'); + }); + + it('should allow users to accept invitation when multi organization is disabled', async () => { + jest.spyOn(mockConfig, 'get').mockImplementation((key: string) => { + switch (key) { + case 'MULTI_ORGANIZATION': + return 'false'; + default: + return process.env[key]; + } + }); + const userData = await createUser(app, { + email: 'organizationUser@tooljet.io', + status: 'invited', + }); + const { user, orgUser } = userData; + + const response = await request(app.getHttpServer()).post('/api/users/accept-invite').send({ + token: orgUser.invitationToken, + password: uuidv4(), + }); + + expect(response.statusCode).toBe(201); + + const organizationUser = await getManager().findOneOrFail(OrganizationUser, { where: { userId: user.id } }); + expect(organizationUser.status).toEqual('active'); + }); }); afterAll(async () => { diff --git a/server/test/services/app_import_export.service.spec.ts b/server/test/services/app_import_export.service.spec.ts index 474aaaf9c4..53c6fa95de 100644 --- a/server/test/services/app_import_export.service.spec.ts +++ b/server/test/services/app_import_export.service.spec.ts @@ -75,7 +75,7 @@ describe('AppImportExportService', () => { kind: 'test_kind', }); - const exportedApp = await getManager().findOne(App, { + const exportedApp = await getManager().findOneOrFail(App, { where: { id: application.id }, relations: ['dataQueries', 'dataSources', 'appVersions'], }); @@ -130,7 +130,7 @@ describe('AppImportExportService', () => { const exportedApp = await service.export(adminUser, app.id); const result = await service.import(adminUser, exportedApp); - const importedApp = await getManager().findOne(App, { + const importedApp = await getManager().findOneOrFail(App, { where: { id: result.id }, relations: ['dataQueries', 'dataSources', 'appVersions'], }); @@ -182,7 +182,7 @@ describe('AppImportExportService', () => { const exportedApp = await service.export(adminUser, application.id); const result = await service.import(adminUser, exportedApp); - const importedApp = await getManager().findOne(App, { + const importedApp = await getManager().findOneOrFail(App, { where: { id: result.id }, relations: ['dataQueries', 'dataSources', 'appVersions'], }); diff --git a/server/src/services/encryption.service.spec.ts b/server/test/services/encryption.service.spec.ts similarity index 95% rename from server/src/services/encryption.service.spec.ts rename to server/test/services/encryption.service.spec.ts index 30f3d947dc..268e341e89 100644 --- a/server/src/services/encryption.service.spec.ts +++ b/server/test/services/encryption.service.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { EncryptionService } from './encryption.service'; +import { EncryptionService } from '../../src/services/encryption.service'; describe('EncryptionService', () => { let service: EncryptionService; diff --git a/server/test/services/folder_apps.service.spec.ts b/server/test/services/folder_apps.service.spec.ts index 8f54f4375e..c2bd9eafe8 100644 --- a/server/test/services/folder_apps.service.spec.ts +++ b/server/test/services/folder_apps.service.spec.ts @@ -30,7 +30,7 @@ describe('FolderAppsService', () => { // add app to folder await service.create(folder.id, app.id); - const newFolder = await manager.findOne(FolderApp, { where: { folderId: folder.id, appId: app.id } }); + const newFolder = await manager.findOneOrFail(FolderApp, { where: { folderId: folder.id, appId: app.id } }); expect(newFolder.folderId).toBe(folder.id); expect(newFolder.appId).toBe(app.id); }); @@ -50,8 +50,9 @@ describe('FolderAppsService', () => { await service.remove(folder.id, app.id); await foldersService.create(adminUser, 'folder'); - const result = await manager.findOne(FolderApp, { where: { folderId: folder.id, appId: app.id } }); - expect(result).toBeUndefined(); + await expect(manager.findOneOrFail(FolderApp, { where: { folderId: folder.id, appId: app.id } })).rejects.toThrow( + expect.any(Error) + ); }); }); diff --git a/server/test/services/users.service.spec.ts b/server/test/services/users.service.spec.ts index b05d859c84..b8dff710b0 100644 --- a/server/test/services/users.service.spec.ts +++ b/server/test/services/users.service.spec.ts @@ -36,21 +36,21 @@ describe('UsersService', () => { firstName: 'John', lastName: 'Wick', }, - adminUser.organization, + adminUser.defaultOrganizationId, ['all_users'] ); const manager = getManager(); - const newUser = await manager.findOne(User, { where: { email: 'john@example.com' } }); + const newUser = await manager.findOneOrFail(User, { where: { email: 'john@example.com' } }); expect(newUser.firstName).toEqual('John'); expect(newUser.lastName).toEqual('Wick'); - expect(newUser.organizationId).toBe(adminUser.organizationId); + expect(newUser.defaultOrganizationId).toBe(adminUser.defaultOrganizationId); // expect default group permission is associated const userGroups = await manager.find(UserGroupPermission, { userId: newUser.id }); expect(userGroups).toHaveLength(1); - const groupPermission = await manager.findOne(GroupPermission, { + const groupPermission = await manager.findOneOrFail(GroupPermission, { where: { id: userGroups[0].groupPermissionId }, }); expect(groupPermission.group).toEqual('all_users'); @@ -78,7 +78,7 @@ describe('UsersService', () => { it('should add user groups', async () => { const { defaultUser } = await setupOrganization(nestApp); - await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + await createGroupPermission(nestApp, { organizationId: defaultUser.defaultOrganizationId, group: 'new-group' }); await service.update(defaultUser.id, { addGroups: ['new-group'] }); await defaultUser.reload(); @@ -90,7 +90,7 @@ describe('UsersService', () => { it('should not add duplicate user groups', async () => { const { defaultUser } = await setupOrganization(nestApp); - await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + await createGroupPermission(nestApp, { organizationId: defaultUser.defaultOrganizationId, group: 'new-group' }); await service.update(defaultUser.id, { addGroups: ['new-group'] }); await defaultUser.reload(); @@ -104,7 +104,7 @@ describe('UsersService', () => { it('should remove user groups', async () => { const { defaultUser } = await setupOrganization(nestApp); - await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + await createGroupPermission(nestApp, { organizationId: defaultUser.defaultOrganizationId, group: 'new-group' }); await service.update(defaultUser.id, { addGroups: ['new-group'] }); await defaultUser.reload(); @@ -118,7 +118,7 @@ describe('UsersService', () => { it('should remove user groups only if it exists', async () => { const { defaultUser } = await setupOrganization(nestApp); - await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + await createGroupPermission(nestApp, { organizationId: defaultUser.defaultOrganizationId, group: 'new-group' }); await service.update(defaultUser.id, { addGroups: ['new-group'] }); await defaultUser.reload(); @@ -147,7 +147,7 @@ describe('UsersService', () => { await service.update(adminUser.id, { addGroups: ['group1'] }); await adminUser.reload(); - await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'group2' }); + await createGroupPermission(nestApp, { organizationId: defaultUser.defaultOrganizationId, group: 'group2' }); await service.update(defaultUser.id, { addGroups: ['group2'] }); await defaultUser.reload(); @@ -185,7 +185,7 @@ describe('UsersService', () => { await getManager().find(GroupPermission, { where: { group: 'all_users', - organizationId: defaultUser.organizationId, + organizationId: defaultUser.defaultOrganizationId, }, }) ).map((gp) => gp.id); @@ -197,7 +197,7 @@ describe('UsersService', () => { describe('.groupPermissionsForOrganization', () => { it('should return all group permissions within organization', async () => { const { defaultUser } = await setupOrganization(nestApp); - const groupPermissions = (await service.groupPermissionsForOrganization(defaultUser.organizationId)).map( + const groupPermissions = (await service.groupPermissionsForOrganization(defaultUser.defaultOrganizationId)).map( (x) => x.group ); diff --git a/server/test/test.helper.ts b/server/test/test.helper.ts index 86d8c96d8e..d428755cf7 100644 --- a/server/test/test.helper.ts +++ b/server/test/test.helper.ts @@ -24,6 +24,7 @@ import { WsAdapter } from '@nestjs/platform-ws'; import { AppsModule } from 'src/modules/apps/apps.module'; import { LibraryAppCreationService } from '@services/library_app_creation.service'; import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { v4 as uuidv4 } from 'uuid'; export async function createNestAppInstance(): Promise { let app: INestApplication; @@ -69,12 +70,17 @@ export async function createNestAppInstanceWithEnvMock(): Promise<{ return { app, mockConfig: moduleRef.get(ConfigService) }; } -export function authHeaderForUser(user: any): string { +export function authHeaderForUser(user: User, organizationId?: string, isPasswordLogin = true): string { const configService = new ConfigService(); const jwtService = new JwtService({ secret: configService.get('SECRET_KEY_BASE'), }); - const authPayload = { username: user.id, sub: user.email }; + const authPayload = { + username: user.id, + sub: user.email, + organizationId: organizationId || user.defaultOrganizationId, + isPasswordLogin, + }; const authToken = jwtService.sign(authPayload); return `Bearer ${authToken}`; } @@ -99,7 +105,7 @@ export async function createApplication(nestApp, { name, user, isPublic, slug }: user, slug, isPublic: isPublic || false, - organizationId: user.organization.id, + organizationId: user.organizationId, createdAt: new Date(), updatedAt: new Date(), }) @@ -132,7 +138,32 @@ export async function createApplicationVersion(nestApp, application, { name = 'v export async function createUser( nestApp, - { firstName, lastName, email, groups, organization, ssoId, status, invitationToken }: any + { + firstName, + lastName, + email, + groups, + organization, + status, + invitationToken, + formLoginStatus = true, + organizationName = 'Test Organization', + ssoConfigs = [], + enableSignUp = false, + }: { + firstName?: string; + lastName?: string; + email?: string; + groups?: Array; + organization?: Organization; + status?: string; + invitationToken?: string; + formLoginStatus?: boolean; + organizationName?: string; + ssoConfigs?: Array; + enableSignUp?: boolean; + }, + existingUser?: User ) { let userRepository: Repository; let organizationRepository: Repository; @@ -146,31 +177,46 @@ export async function createUser( organization || (await organizationRepository.save( organizationRepository.create({ - name: 'Test Organization', + name: organizationName, + enableSignUp, createdAt: new Date(), updatedAt: new Date(), + ssoConfigs: [ + { + sso: 'form', + enabled: formLoginStatus, + }, + ...ssoConfigs, + ], }) )); - const user = await userRepository.save( - userRepository.create({ - firstName: firstName || 'test', - lastName: lastName || 'test', - email: email || 'dev@tooljet.io', - password: 'password', - invitationToken, - organization, - ssoId, - createdAt: new Date(), - updatedAt: new Date(), - }) - ); + let user: User; + + if (!existingUser) { + user = await userRepository.save( + userRepository.create({ + firstName: firstName || 'test', + lastName: lastName || 'test', + email: email || 'dev@tooljet.io', + password: 'password', + invitationToken, + defaultOrganizationId: organization.id, + createdAt: new Date(), + updatedAt: new Date(), + }) + ); + } else { + user = existingUser; + } + user.organizationId = organization.id; const orgUser = await organizationUsersRepository.save( organizationUsersRepository.create({ user: user, organization, - status: status || 'invited', + invitationToken: status === 'invited' ? uuidv4() : null, + status: status || 'active', role: 'all_users', createdAt: new Date(), updatedAt: new Date(), @@ -345,7 +391,7 @@ export async function addAllUsersGroupToUser(nestApp, user) { const groupPermissionRepository: Repository = nestApp.get('GroupPermissionRepository'); const userGroupPermissionRepository: Repository = nestApp.get('UserGroupPermissionRepository'); - const orgDefaultGroupPermissions = await groupPermissionRepository.findOne({ + const orgDefaultGroupPermissions = await groupPermissionRepository.findOneOrFail({ where: { organizationId: user.organizationId, group: 'all_users',