Skip to content
Snippets Groups Projects
EditSandboxingModal.tsx 12.9 KiB
Newer Older
import cx from "classnames";
import type * as React from "react";
import { useState } from "react";
import { useAsyncFn } from "react-use";
import { jt, t } from "ttag";
import _ from "underscore";
import { useGetCardQuery, skipToken, useGetTableQuery } from "metabase/api";
import { QuestionPickerModal } from "metabase/common/components/QuestionPicker";
import ActionButton from "metabase/components/ActionButton";
import QuestionLoader from "metabase/containers/QuestionLoader";
import Radio from "metabase/core/components/Radio";
import CS from "metabase/css/core/index.css";
import { EntityName } from "metabase/entities/containers/EntityName";
import { useToggle } from "metabase/hooks/use-toggle";
import { GTAPApi } from "metabase/services";
import type { IconName } from "metabase/ui";
import { Icon, Button } from "metabase/ui";
  GroupTableAccessPolicyDraft,
  GroupTableAccessPolicyParams,
} from "metabase-enterprise/sandboxes/types";
import { getRawDataQuestionForTable } from "metabase-enterprise/sandboxes/utils";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type {
  GroupTableAccessPolicy,
  Table,
  UserAttribute,
} from "metabase-types/api";
import AttributeMappingEditor, {
  AttributeOptionsEmptyState,
} from "../AttributeMappingEditor";

const ERROR_MESSAGE = t`An error occurred.`;

const getNormalizedPolicy = (
  policy: GroupTableAccessPolicy | GroupTableAccessPolicyDraft,
  shouldUseSavedQuestion: boolean,
): GroupTableAccessPolicy => {
  return {
    ...policy,
    card_id: shouldUseSavedQuestion ? policy.card_id : null,
    attribute_remappings: _.pick(
      policy.attribute_remappings,
      (value, key) => value != null && key != null,
    ),
  } as GroupTableAccessPolicy;
};

const getDraftPolicy = ({
  tableId,
  groupId,
}: GroupTableAccessPolicyParams): GroupTableAccessPolicyDraft => {
  return {
    table_id: parseInt(tableId),
    group_id: parseInt(groupId),
    card_id: null,
    attribute_remappings: { "": null },
  };
};

const isPolicyValid = (
  policy: GroupTableAccessPolicy,
  shouldUseSavedQuestion: boolean,
) => {
  if (shouldUseSavedQuestion) {
    return policy.card_id != null;
  }

  return Object.entries(policy.attribute_remappings).length > 0;
};

export interface EditSandboxingModalProps {
  policy?: GroupTableAccessPolicy;
  attributes: UserAttribute[];
  params: GroupTableAccessPolicyParams;
  onCancel: () => void;
  onSave: (policy: GroupTableAccessPolicy) => void;
}

const EditSandboxingModal = ({
  policy: originalPolicy,
  attributes,
  params,
  onCancel,
  onSave,
}: EditSandboxingModalProps) => {
  const [policy, setPolicy] = useState<
    GroupTableAccessPolicy | GroupTableAccessPolicyDraft
  >(originalPolicy ?? getDraftPolicy(params));
  const [shouldUseSavedQuestion, setShouldUseSavedQuestion] = useState(
    policy.card_id != null,
  );

  const normalizedPolicy = getNormalizedPolicy(policy, shouldUseSavedQuestion);
  const isValid = isPolicyValid(normalizedPolicy, shouldUseSavedQuestion);

  const [showPickerModal, { turnOn: showModal, turnOff: hideModal }] =
    useToggle(false);

  const [{ error }, savePolicy] = useAsyncFn(async () => {
    const shouldValidate = normalizedPolicy.card_id != null;
    if (shouldValidate) {
      await GTAPApi.validate(normalizedPolicy);
    }
    onSave(normalizedPolicy);
  }, [normalizedPolicy]);

  const remainingAttributesOptions = attributes.filter(
    attribute => !(attribute in policy.attribute_remappings),
  );

  const hasAttributesOptions = attributes.length > 0;
  const hasValidMappings =
    Object.keys(normalizedPolicy.attribute_remappings || {}).length > 0;

  const canSave =
    isValid &&
    (!_.isEqual(originalPolicy, normalizedPolicy) ||
      normalizedPolicy.id == null);

  const { data: policyCard } = useGetCardQuery(
    policy.card_id != null ? { id: policy.card_id } : skipToken,
  );
  const { data: policyTable } = useGetTableQuery(
    policy.table_id != null ? { id: policy.table_id } : skipToken,
  );
      <h2 className={CS.p3}>{t`Restrict access to this table`}</h2>
        <div className={cx(CS.px3, CS.pb3)}>
          <div className={CS.pb2}>
            {t`When the following rules are applied, this group will see a customized version of the table.`}
          <div className={CS.pb4}>
            {t`These rules don’t apply to native queries.`}
          </div>
          <h4 className={CS.pb1}>{t`How do you want to filter this table?`}</h4>
          <Radio
            value={!shouldUseSavedQuestion}
            options={[
              { name: t`Filter by a column in the table`, value: true },
                name: t`Use a saved question to create a custom view for this table`,
                value: false,
              },
            ]}
            onChange={shouldUseSavedQuestion =>
              setShouldUseSavedQuestion(!shouldUseSavedQuestion)
            }
            vertical
          />
        </div>
        {shouldUseSavedQuestion && (
          <div className={cx(CS.px3, CS.pb3)}>
            <div className={CS.pb2}>
              {t`Pick a saved question that returns the custom view of this table that these users should see.`}
            </div>
            <Button
              data-testid="collection-picker-button"
              onClick={showModal}
              fullWidth
              rightIcon={<Icon name="ellipsis" />}
              styles={{
                inner: {
                  justifyContent: "space-between",
                },
                root: { "&:active": { transform: "none" } },
              }}
            >
              {policyCard?.name ?? t`Select a question`}
            </Button>
            {showPickerModal && (
              <QuestionPickerModal
                value={
                    ? {
                        id: policy.card_id,
                        model: policyCard.type === "model" ? "dataset" : "card",
                      }
                    : undefined
                }
                onChange={newCard => {
                  setPolicy({ ...policy, card_id: newCard.id });
                  hideModal();
                }}
                onClose={hideModal}
              />
            )}
          </div>
        )}
        {(!shouldUseSavedQuestion || policy.card_id != null) &&
          (hasAttributesOptions || hasValidMappings ? (
            <div className={cx(CS.p3, CS.borderTop, CS.borderBottom)}>
                <div className={CS.pb2}>
                  {t`You can optionally add additional filters here based on user attributes. These filters will be applied on top of any filters that are already in this saved question.`}
                </div>
              )}
              <AttributeMappingEditor
                value={policy.attribute_remappings}
                onChange={attribute_remappings =>
                  setPolicy({ ...policy, attribute_remappings })
                }
                shouldUseSavedQuestion={shouldUseSavedQuestion}
                policy={policy}
                attributesOptions={remainingAttributesOptions}
              />
            </div>
          ) : (
            <div className={CS.px3}>
              <AttributeOptionsEmptyState
                title={
                  shouldUseSavedQuestion
                    ? t`To add additional filters, your users need to have some attributes`
                    : t`For this option to work, your users need to have some attributes`
                }
              />
            </div>
          ))}
      </div>

      <div className={CS.p3}>
          <div className={CS.pb1}>
            <PolicySummary
              policy={normalizedPolicy}
              policyTable={policyTable}
            />
        <div className={cx(CS.flex, CS.alignCenter, CS.justifyEnd)}>
          <Button onClick={onCancel}>{t`Cancel`}</Button>
          <ActionButton
            error={error}
            actionFn={savePolicy}
            primary
            disabled={!canSave}
          >
            {t`Save`}
          </ActionButton>
        </div>
        {error && (
          <div className={cx(CS.flex, CS.alignCenter, CS.my2, CS.textError)}>
              : // @ts-expect-error provide correct type for error
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default EditSandboxingModal;

interface SummaryRowProps {
Maz Ameli's avatar
Maz Ameli committed
  icon: IconName;
  content: React.ReactNode;
}

const SummaryRow = ({ icon, content }: SummaryRowProps) => (
  <div className={cx(CS.flex, CS.alignCenter)}>
    <Icon className={CS.p1} name={icon} />
    <span>{content}</span>
  </div>
);

interface PolicySummaryProps {
  policy: GroupTableAccessPolicy;
const PolicySummary = ({ policy, policyTable }: PolicySummaryProps) => {
      <div className={cx(CS.px1, CS.pb2, CS.textUppercase, CS.textSmall)}>
      </div>
      <SummaryRow
        icon="group"
        content={jt`Users in ${(
            <EntityName entityType="groups" entityId={policy.group_id} />
          </strong>
        )} can view`}
      />
      <SummaryRow
        icon="table"
        content={
          policy.card_id
            ? jt`rows in the ${(
                  <EntityName
                    entityType="questions"
                    entityId={policy.card_id}
                  />
                </strong>
              )} question`
            : jt`rows in the ${(
                  <EntityName
                    entityType="tables"
                    entityId={policy.table_id}
                    property="display_name"
                  />
                </strong>
              )} table`
        }
      />
      {Object.entries(policy.attribute_remappings).map(
        ([attribute, target], index) => (
          <SummaryRow
            key={attribute}
Maz Ameli's avatar
Maz Ameli committed
            icon="funnel_outline"
                    <TargetName
                      key="target"
                      policy={policy}
                      policyTable={policyTable}
                      target={target}
                    />
                    <span key="attr" className={CS.textCode}>
                    <TargetName
                      key="target"
                      policy={policy}
                      policyTable={policyTable}
                      target={target}
                    />
                    <span key="attr" className={CS.textCode}>
            }
          />
        ),
      )}
    </div>
  );
};

interface TargetNameProps {
  policy: GroupTableAccessPolicy;
const TargetName = ({ policy, policyTable, target }: TargetNameProps) => {
  if (Array.isArray(target)) {
    if (
      (target[0] === "variable" || target[0] === "dimension") &&
      target[1][0] === "template-tag"
    ) {
      return (
        <span>
          <strong>{target[1][1]}</strong> variable
        </span>
      );
    } else if (target[0] === "dimension") {
      const fieldRef = target[1];

      return (
        <QuestionLoader
          questionId={policy.card_id}
          questionObject={
            policy.card_id == null && policyTable
              ? getRawDataQuestionForTable(policyTable)
              : null
          }
        >
          {({ question }: { question: Question }) => {
            if (!question) {
              return null;
            }

            const query = question.query();
            const stageIndex = -1;
            const columns = Lib.visibleColumns(query, stageIndex);
            const [index] = Lib.findColumnIndexesFromLegacyRefs(
              query,
              stageIndex,
              columns,
              [fieldRef],
            );
            const column = columns[index];
            if (!column) {
              return null;
            }

            const columnInfo = Lib.displayInfo(query, stageIndex, column);
                <strong>{columnInfo.displayName}</strong> field
              </span>
            );
          }}
        </QuestionLoader>
      );
    }
  }
  return <strong>[{t`Unknown target`}]</strong>;
};