From 12800961833cf03232d72191d5d17d71b1aa2935 Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Fri, 24 Sep 2021 21:55:31 +0300
Subject: [PATCH] Fix approved domains validation for alerts and dashboard
 subscriptions (#18049)

---
 .../advanced_config/models/pulse_channel.clj  |  3 +-
 .../metabase/components/form/FormMessage.jsx  | 30 ++++--
 .../metabase/dashboard/components/Sidebar.jsx | 20 +++-
 .../dashboard/components/Sidebar.styled.jsx   | 11 +++
 .../src/metabase/query_builder/actions.js     |  1 -
 .../query_builder/components/AlertModals.jsx  | 92 ++++++++++++-------
 .../components/AlertModals.styled.jsx         | 14 +++
 .../query_builder/containers/QueryBuilder.jsx |  3 +-
 .../sharing/components/AddEditSidebar.jsx     | 12 ++-
 .../sharing/components/SharingSidebar.jsx     | 26 ++++--
 10 files changed, 156 insertions(+), 56 deletions(-)
 create mode 100644 frontend/src/metabase/dashboard/components/Sidebar.styled.jsx
 create mode 100644 frontend/src/metabase/query_builder/components/AlertModals.styled.jsx

diff --git a/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj b/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj
index 58f544822b3..754c819686d 100644
--- a/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj
+++ b/enterprise/backend/src/metabase_enterprise/advanced_config/models/pulse_channel.clj
@@ -39,5 +39,4 @@
                                (pr-str domain)
                                (str/join ", " allowed-domains))
                           {:email           email
-                           :allowed-domains allowed-domains
-                           :status-code     403})))))))
+                           :allowed-domains allowed-domains})))))))
diff --git a/frontend/src/metabase/components/form/FormMessage.jsx b/frontend/src/metabase/components/form/FormMessage.jsx
index f7b0bf04057..7508327fabb 100644
--- a/frontend/src/metabase/components/form/FormMessage.jsx
+++ b/frontend/src/metabase/components/form/FormMessage.jsx
@@ -11,15 +11,9 @@ export default class FormMessage extends Component {
 
     if (!message) {
       if (formError) {
-        if (formError.data && formError.data.message) {
-          message = formError.data.message;
-        } else if (formError.status >= 400) {
-          message = SERVER_ERROR_MESSAGE;
-        } else {
-          message = UNKNOWN_ERROR_MESSAGE;
-        }
-      } else if (formSuccess && formSuccess.data.message) {
-        message = formSuccess.data.message;
+        message = getErrorMessage(formError);
+      } else if (formSuccess) {
+        message = getSuccessMessage(formSuccess);
       }
     }
 
@@ -32,3 +26,21 @@ export default class FormMessage extends Component {
     return <span className={classes}>{message}</span>;
   }
 }
+
+export const getErrorMessage = formError => {
+  if (formError) {
+    if (formError.data && formError.data.message) {
+      return formError.data.message;
+    } else if (formError.status >= 400) {
+      return SERVER_ERROR_MESSAGE;
+    } else {
+      return UNKNOWN_ERROR_MESSAGE;
+    }
+  }
+};
+
+export const getSuccessMessage = formSuccess => {
+  if (formSuccess && formSuccess.data.message) {
+    return formSuccess.data.message;
+  }
+};
diff --git a/frontend/src/metabase/dashboard/components/Sidebar.jsx b/frontend/src/metabase/dashboard/components/Sidebar.jsx
index 2e75eec5f5c..0779f400586 100644
--- a/frontend/src/metabase/dashboard/components/Sidebar.jsx
+++ b/frontend/src/metabase/dashboard/components/Sidebar.jsx
@@ -1,12 +1,23 @@
 /* eslint-disable react/prop-types */
 import React from "react";
+import PropTypes from "prop-types";
 import { t } from "ttag";
 
 import Button from "metabase/components/Button";
+import { getErrorMessage } from "metabase/components/form/FormMessage";
+import { SidebarError, SidebarFooter } from "./Sidebar.styled";
 
 const WIDTH = 384;
 
-function Sidebar({ onClose, onCancel, closeIsDisabled, children }) {
+const propTypes = {
+  closeIsDisabled: PropTypes.bool,
+  formError: PropTypes.object,
+  children: PropTypes.node,
+  onClose: PropTypes.func,
+  onCancel: PropTypes.func,
+};
+
+function Sidebar({ closeIsDisabled, formError, children, onClose, onCancel }) {
   return (
     <aside
       style={{ width: WIDTH, minWidth: WIDTH }}
@@ -39,8 +50,15 @@ function Sidebar({ onClose, onCancel, closeIsDisabled, children }) {
           )}
         </div>
       )}
+      {formError && (
+        <SidebarFooter>
+          <SidebarError>{getErrorMessage(formError)}</SidebarError>
+        </SidebarFooter>
+      )}
     </aside>
   );
 }
 
+Sidebar.propTypes = propTypes;
+
 export default Sidebar;
diff --git a/frontend/src/metabase/dashboard/components/Sidebar.styled.jsx b/frontend/src/metabase/dashboard/components/Sidebar.styled.jsx
new file mode 100644
index 00000000000..82233ec8db6
--- /dev/null
+++ b/frontend/src/metabase/dashboard/components/Sidebar.styled.jsx
@@ -0,0 +1,11 @@
+import styled from "styled-components";
+import { color } from "metabase/lib/colors";
+import { space } from "metabase/styled-components/theme";
+
+export const SidebarFooter = styled.div`
+  padding: 0 ${space(3)} ${space(2)};
+`;
+
+export const SidebarError = styled.div`
+  color: ${color("error")};
+`;
diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index 7584e5ac047..5928764385d 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -1045,7 +1045,6 @@ export const apiUpdateQuestion = question => {
     // so we want the databases list to be re-fetched next time we hit "New Question" so it shows up
     dispatch(setRequestUnloaded(["entities", "databases"]));
 
-    dispatch(updateUrl(updatedQuestion.card(), { dirty: false }));
     MetabaseAnalytics.trackEvent(
       "QueryBuilder",
       "Update Card",
diff --git a/frontend/src/metabase/query_builder/components/AlertModals.jsx b/frontend/src/metabase/query_builder/components/AlertModals.jsx
index f3d2e346320..03d0db7b68a 100644
--- a/frontend/src/metabase/query_builder/components/AlertModals.jsx
+++ b/frontend/src/metabase/query_builder/components/AlertModals.jsx
@@ -15,12 +15,14 @@ import Icon from "metabase/components/Icon";
 import ChannelSetupModal from "metabase/components/ChannelSetupModal";
 import ButtonWithStatus from "metabase/components/ButtonWithStatus";
 import PulseEditChannels from "metabase/pulse/components/PulseEditChannels";
+import { getErrorMessage } from "metabase/components/form/FormMessage";
+import { AlertModalFooter, AlertModalError } from "./AlertModals.styled";
 
 import User from "metabase/entities/users";
 
 // actions
 import { createAlert, deleteAlert, updateAlert } from "metabase/alert/alert";
-import { apiUpdateQuestion } from "metabase/query_builder/actions";
+import { apiUpdateQuestion, updateUrl } from "metabase/query_builder/actions";
 import { fetchPulseFormInput } from "metabase/pulse/actions";
 
 // selectors
@@ -71,7 +73,7 @@ const textStyle = {
     hasConfiguredAnyChannel: hasConfiguredAnyChannelSelector(state),
     hasConfiguredEmailChannel: hasConfiguredEmailChannelSelector(state),
   }),
-  { createAlert, fetchPulseFormInput, apiUpdateQuestion },
+  { createAlert, fetchPulseFormInput, apiUpdateQuestion, updateUrl },
 )
 export class CreateAlertModalContent extends Component {
   props: {
@@ -87,6 +89,7 @@ export class CreateAlertModalContent extends Component {
     this.state = {
       hasSeenEducationalScreen: MetabaseCookies.getHasSeenAlertSplash(),
       alert: getDefaultAlert(question, user, visualizationSettings),
+      formError: null,
     };
   }
 
@@ -113,21 +116,28 @@ export class CreateAlertModalContent extends Component {
   onAlertChange = alert => this.setState({ alert });
 
   onCreateAlert = async () => {
-    const { createAlert, apiUpdateQuestion, onAlertCreated } = this.props;
+    const {
+      question,
+      createAlert,
+      apiUpdateQuestion,
+      updateUrl,
+      onAlertCreated,
+    } = this.props;
     const { alert } = this.state;
 
-    // Resave the question here (for persisting the x/y axes; see #6749)
-    await apiUpdateQuestion();
+    try {
+      this.setState({ formError: null });
 
-    await createAlert(alert);
+      await apiUpdateQuestion(question);
+      await createAlert(alert);
+      await updateUrl(question.card(), { dirty: false });
 
-    // should close be triggered manually like this
-    // but the creation notification would appear automatically ...?
-    // OR should the modal visibility be part of QB redux state
-    // (maybe check how other modals are implemented)
-    onAlertCreated();
-
-    MetabaseAnalytics.trackEvent("Alert", "Create", alert.alert_condition);
+      onAlertCreated();
+      MetabaseAnalytics.trackEvent("Alert", "Create", alert.alert_condition);
+    } catch (e) {
+      this.setState({ formError: e });
+      throw e;
+    }
   };
 
   proceedFromEducationalScreen = () => {
@@ -146,7 +156,7 @@ export class CreateAlertModalContent extends Component {
       user,
       hasLoadedChannelInfo,
     } = this.props;
-    const { alert, hasSeenEducationalScreen } = this.state;
+    const { alert, hasSeenEducationalScreen, formError } = this.state;
 
     const channelRequirementsMet = isAdmin
       ? hasConfiguredAnyChannel
@@ -186,14 +196,16 @@ export class CreateAlertModalContent extends Component {
             alert={alert}
             onAlertChange={this.onAlertChange}
           />
-          <div className="flex align-center mt4">
-            <div className="flex-full" />
+          <AlertModalFooter>
+            {formError && (
+              <AlertModalError>{getErrorMessage(formError)}</AlertModalError>
+            )}
             <Button onClick={onCancel} className="mr2">{t`Cancel`}</Button>
             <ButtonWithStatus
               titleForState={{ default: t`Done` }}
               onClickOperation={this.onCreateAlert}
             />
-          </div>
+          </AlertModalFooter>
         </div>
       </ModalContent>
     );
@@ -290,7 +302,7 @@ export class AlertEducationalScreen extends Component {
     question: getQuestion(state),
     visualizationSettings: getVisualizationSettings(state),
   }),
-  { apiUpdateQuestion, updateAlert, deleteAlert },
+  { apiUpdateQuestion, updateAlert, deleteAlert, updateUrl },
 )
 export class UpdateAlertModalContent extends Component {
   props: {
@@ -306,26 +318,40 @@ export class UpdateAlertModalContent extends Component {
     super();
     this.state = {
       modifiedAlert: props.alert,
+      formError: null,
     };
   }
 
   onAlertChange = modifiedAlert => this.setState({ modifiedAlert });
 
   onUpdateAlert = async () => {
-    const { apiUpdateQuestion, updateAlert, onAlertUpdated } = this.props;
+    const {
+      question,
+      apiUpdateQuestion,
+      updateAlert,
+      updateUrl,
+      onAlertUpdated,
+    } = this.props;
     const { modifiedAlert } = this.state;
 
-    // Resave the question here (for persisting the x/y axes; see #6749)
-    await apiUpdateQuestion();
+    try {
+      this.setState({ formError: null });
 
-    await updateAlert(modifiedAlert);
-    onAlertUpdated();
+      await apiUpdateQuestion();
+      await updateAlert(modifiedAlert);
+      await updateUrl(question.card(), { dirty: false });
 
-    MetabaseAnalytics.trackEvent(
-      "Alert",
-      "Update",
-      modifiedAlert.alert_condition,
-    );
+      onAlertUpdated();
+
+      MetabaseAnalytics.trackEvent(
+        "Alert",
+        "Update",
+        modifiedAlert.alert_condition,
+      );
+    } catch (e) {
+      this.setState({ formError: e });
+      throw e;
+    }
   };
 
   onDeleteAlert = async () => {
@@ -343,7 +369,7 @@ export class UpdateAlertModalContent extends Component {
       user,
       isAdmin,
     } = this.props;
-    const { modifiedAlert } = this.state;
+    const { modifiedAlert, formError } = this.state;
 
     const isCurrentUser = alert.creator.id === user.id;
     const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
@@ -367,14 +393,16 @@ export class UpdateAlertModalContent extends Component {
             />
           )}
 
-          <div className="flex align-center mt4">
-            <div className="flex-full" />
+          <AlertModalFooter>
+            {formError && (
+              <AlertModalError>{getErrorMessage(formError)}</AlertModalError>
+            )}
             <Button onClick={onCancel} className="mr2">{t`Cancel`}</Button>
             <ButtonWithStatus
               titleForState={{ default: t`Save changes` }}
               onClickOperation={this.onUpdateAlert}
             />
-          </div>
+          </AlertModalFooter>
         </div>
       </ModalContent>
     );
diff --git a/frontend/src/metabase/query_builder/components/AlertModals.styled.jsx b/frontend/src/metabase/query_builder/components/AlertModals.styled.jsx
new file mode 100644
index 00000000000..ed338861bfe
--- /dev/null
+++ b/frontend/src/metabase/query_builder/components/AlertModals.styled.jsx
@@ -0,0 +1,14 @@
+import styled from "styled-components";
+import { space } from "metabase/styled-components/theme";
+import { color } from "metabase/lib/colors";
+
+export const AlertModalFooter = styled.div`
+  display: flex;
+  justify-content: right;
+  align-items: center;
+  margin-top: ${space(3)};
+`;
+
+export const AlertModalError = styled.div`
+  color: ${color("error")};
+`;
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index 87049a712c2..6e5017cf152 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -253,9 +253,10 @@ export default class QueryBuilder extends Component {
   };
 
   handleSave = async card => {
-    const { question, apiUpdateQuestion } = this.props;
+    const { question, apiUpdateQuestion, updateUrl } = this.props;
     const questionWithUpdatedCard = question.setCard(card);
     await apiUpdateQuestion(questionWithUpdatedCard);
+    await updateUrl(questionWithUpdatedCard.card(), { dirty: false });
 
     if (this.props.fromUrl) {
       this.props.onChangeLocation(this.props.fromUrl);
diff --git a/frontend/src/metabase/sharing/components/AddEditSidebar.jsx b/frontend/src/metabase/sharing/components/AddEditSidebar.jsx
index efe7be6cddf..89674b075e8 100644
--- a/frontend/src/metabase/sharing/components/AddEditSidebar.jsx
+++ b/frontend/src/metabase/sharing/components/AddEditSidebar.jsx
@@ -56,6 +56,7 @@ export const AddEditSlackSidebar = connect(mapStateToProps)(
 function _AddEditEmailSidebar({
   pulse,
   formInput,
+  formError,
   channel,
   channelSpec,
   users,
@@ -76,10 +77,10 @@ function _AddEditEmailSidebar({
 }) {
   return (
     <Sidebar
+      closeIsDisabled={!dashboardPulseIsValid(pulse, formInput.channels)}
+      formError={formError}
       onClose={handleSave}
       onCancel={onCancel}
-      className="text-dark"
-      closeIsDisabled={!dashboardPulseIsValid(pulse, formInput.channels)}
     >
       <div className="pt4 px4 flex align-center">
         <Icon name="mail" className="mr1" size={21} />
@@ -187,6 +188,7 @@ function _AddEditEmailSidebar({
 _AddEditEmailSidebar.propTypes = {
   pulse: PropTypes.object.isRequired,
   formInput: PropTypes.object.isRequired,
+  formError: PropTypes.object,
   channel: PropTypes.object.isRequired,
   channelSpec: PropTypes.object.isRequired,
   users: PropTypes.array,
@@ -269,6 +271,7 @@ function getConfirmItems(pulse) {
 function _AddEditSlackSidebar({
   pulse,
   formInput,
+  formError,
   channel,
   channelSpec,
   parameters,
@@ -286,10 +289,10 @@ function _AddEditSlackSidebar({
 }) {
   return (
     <Sidebar
+      closeIsDisabled={!dashboardPulseIsValid(pulse, formInput.channels)}
+      formError={formError}
       onClose={handleSave}
       onCancel={onCancel}
-      className="text-dark"
-      closeIsDisabled={!dashboardPulseIsValid(pulse, formInput.channels)}
     >
       <div className="pt4 flex align-center px4 mb3">
         <Icon name="slack" className="mr1" size={21} />
@@ -368,6 +371,7 @@ function _AddEditSlackSidebar({
 _AddEditSlackSidebar.propTypes = {
   pulse: PropTypes.object.isRequired,
   formInput: PropTypes.object.isRequired,
+  formError: PropTypes.object,
   channel: PropTypes.object.isRequired,
   channelSpec: PropTypes.object.isRequired,
   users: PropTypes.array,
diff --git a/frontend/src/metabase/sharing/components/SharingSidebar.jsx b/frontend/src/metabase/sharing/components/SharingSidebar.jsx
index d2d3713cec7..ee7f79c3bb8 100644
--- a/frontend/src/metabase/sharing/components/SharingSidebar.jsx
+++ b/frontend/src/metabase/sharing/components/SharingSidebar.jsx
@@ -120,6 +120,8 @@ class SharingSidebar extends React.Component {
     editingMode: "list-pulses",
     // use this to know where to go "back" to
     returnMode: [],
+    isSaving: false,
+    formError: null,
   };
 
   static propTypes = {
@@ -200,6 +202,11 @@ class SharingSidebar extends React.Component {
 
   handleSave = async () => {
     const { pulse, dashboard, formInput } = this.props;
+    const { isSaving } = this.state;
+
+    if (isSaving) {
+      return;
+    }
 
     const cleanedPulse = cleanPulse(pulse, formInput.channels);
     cleanedPulse.name = dashboard.name;
@@ -225,11 +232,16 @@ class SharingSidebar extends React.Component {
       },
     );
 
-    await this.props.updateEditingPulse(cleanedPulse);
-
-    // The order below matters; it hides the "Done" button faster and prevents two pulses from being made if it's double-clicked
-    this.setState({ editingMode: "list-pulses", returnMode: [] });
-    await this.props.saveEditingPulse();
+    try {
+      this.setState({ isSaving: true, formError: null });
+      await this.props.updateEditingPulse(cleanedPulse);
+      await this.props.saveEditingPulse();
+      this.setState({ editingMode: "list-pulses", returnMode: [] });
+    } catch (e) {
+      this.setState({ formError: e });
+    } finally {
+      this.setState({ isSaving: false });
+    }
   };
 
   createSubscription = () => {
@@ -275,7 +287,7 @@ class SharingSidebar extends React.Component {
   };
 
   render() {
-    const { editingMode } = this.state;
+    const { editingMode, formError } = this.state;
     const {
       pulse,
       pulses,
@@ -321,6 +333,7 @@ class SharingSidebar extends React.Component {
         <AddEditEmailSidebar
           pulse={pulse}
           formInput={formInput}
+          formError={formError}
           channel={channel}
           channelSpec={channelSpec}
           handleSave={this.handleSave}
@@ -363,6 +376,7 @@ class SharingSidebar extends React.Component {
         <AddEditSlackSidebar
           pulse={pulse}
           formInput={formInput}
+          formError={formError}
           channel={channel}
           channelSpec={channelSpec}
           handleSave={this.handleSave}
-- 
GitLab