diff --git a/e2e/snapshot-creators/default.cy.snap.js b/e2e/snapshot-creators/default.cy.snap.js index 1951da5c3d55f..a86a64e03844e 100644 --- a/e2e/snapshot-creators/default.cy.snap.js +++ b/e2e/snapshot-creators/default.cy.snap.js @@ -91,7 +91,10 @@ describe("snapshots", () => { function updateSettings() { updateSetting("enable-public-sharing", true); - updateSetting("enable-embedding", true).then(() => { + // interactive is not enabled in the snapshots as it requires a premium feature + // updateSetting("enable-embedding-interactive", true); + updateSetting("enable-embedding-sdk", true); + updateSetting("enable-embedding-static", true).then(() => { updateSetting("embedding-secret-key", METABASE_SECRET_KEY); }); diff --git a/e2e/support/commands/embedding-sdk/utils.ts b/e2e/support/commands/embedding-sdk/utils.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/e2e/support/config.js b/e2e/support/config.js index 7c07011d29a9d..9581cb1d9d55b 100644 --- a/e2e/support/config.js +++ b/e2e/support/config.js @@ -8,6 +8,7 @@ import { verifyDownloadTasks, } from "./commands/downloads/downloadUtils"; import * as dbTasks from "./db_tasks"; +import { signJwt } from "./helpers/e2e-jwt-tasks"; const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); // This function is called when a project is opened or re-opened (e.g. due to the project's config changing) const { @@ -27,6 +28,8 @@ const targetVersion = process.env["CROSS_VERSION_TARGET"]; const feHealthcheckEnabled = process.env["CYPRESS_FE_HEALTHCHECK"] === "true"; +const isEmbeddingSdk = process.env.CYPRESS_IS_EMBEDDING_SDK === "true"; + // docs say that tsconfig paths should handle aliases, but they don't const assetsResolverPlugin = { name: "assetsResolver", @@ -104,6 +107,7 @@ const defaultConfig = { ...dbTasks, ...verifyDownloadTasks, removeDirectory, + signJwt, }); // this is an official workaround to keep recordings of the failed specs only @@ -169,6 +173,14 @@ const defaultConfig = { const mainConfig = { ...defaultConfig, + ...(isEmbeddingSdk + ? { + chromeWebSecurity: true, + hosts: { + "my-site.local": "127.0.0.1", + }, + } + : {}), projectId: "ywjy9z", numTestsKeptInMemory: process.env["CI"] ? 1 : 50, reporter: "cypress-multi-reporters", diff --git a/e2e/support/helpers/e2e-embedding-helpers.js b/e2e/support/helpers/e2e-embedding-helpers.js index c6f03faf7158d..934ab36c53182 100644 --- a/e2e/support/helpers/e2e-embedding-helpers.js +++ b/e2e/support/helpers/e2e-embedding-helpers.js @@ -169,7 +169,7 @@ export function openStaticEmbeddingModal({ cy.findByRole("button", { name: "Save" }).click(); } - cy.findByTestId("sharing-pane-static-embed-button").click(); + cy.findByText("Static embedding").click(); if (acceptTerms) { cy.findByTestId("accept-legalese-terms-button").click(); diff --git a/e2e/support/helpers/e2e-jwt-tasks.ts b/e2e/support/helpers/e2e-jwt-tasks.ts new file mode 100644 index 0000000000000..6b9f36d14b62d --- /dev/null +++ b/e2e/support/helpers/e2e-jwt-tasks.ts @@ -0,0 +1,11 @@ +import jwt from "jsonwebtoken"; + +export function signJwt({ + payload, + secret, +}: { + payload: Record; + secret: string; +}): string { + return jwt.sign(payload, secret); +} diff --git a/e2e/test/scenarios/embedding-sdk/static-dashboard-cors.cy.spec.js b/e2e/test/scenarios/embedding-sdk/static-dashboard-cors.cy.spec.js new file mode 100644 index 0000000000000..6609183ba74f8 --- /dev/null +++ b/e2e/test/scenarios/embedding-sdk/static-dashboard-cors.cy.spec.js @@ -0,0 +1,199 @@ +import { USERS } from "e2e/support/cypress_data"; +import { + ORDERS_DASHBOARD_DASHCARD_ID, + ORDERS_QUESTION_ID, +} from "e2e/support/cypress_sample_instance_data"; +import { + getTextCardDetails, + restore, + setTokenFeatures, + visitFullAppEmbeddingUrl, +} from "e2e/support/helpers"; +import { + EMBEDDING_SDK_STORY_HOST, + describeSDK, +} from "e2e/support/helpers/e2e-embedding-sdk-helpers"; +import { + JWT_SHARED_SECRET, + setupJwt, +} from "e2e/support/helpers/e2e-jwt-helpers"; + +const STORYBOOK_ID = "embeddingsdk-cypressstaticdashboardwithcors--default"; +describeSDK("scenarios > embedding-sdk > static-dashboard", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setTokenFeatures("all"); + setupJwt(); + + const textCard = getTextCardDetails({ col: 16, text: "Text text card" }); + const questionCard = { + id: ORDERS_DASHBOARD_DASHCARD_ID, + card_id: ORDERS_QUESTION_ID, + row: 0, + col: 0, + size_x: 16, + size_y: 8, + visualization_settings: { + "card.title": "Test question card", + }, + }; + + cy.createDashboard( + { + name: "Embedding Sdk Test Dashboard", + dashcards: [questionCard, textCard], + }, + { wrapId: true }, + ); + + cy.intercept("GET", "/api/dashboard/*").as("getDashboard"); + cy.intercept("GET", "/api/user/current").as("getUser"); + cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as( + "dashcardQuery", + ); + + cy.task("signJwt", { + payload: { + email: USERS.normal.email, + exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration + }, + secret: JWT_SHARED_SECRET, + }).then(jwtToken => { + const ssoUrl = new URL("/auth/sso", Cypress.config().baseUrl); + ssoUrl.searchParams.set("jwt", jwtToken); + ssoUrl.searchParams.set("token", "true"); + cy.request(ssoUrl.toString()).then(({ body }) => { + cy.wrap(body).as("metabaseSsoResponse"); + }); + }); + cy.get("@metabaseSsoResponse").then(ssoResponse => { + cy.intercept("GET", "/sso/metabase", ssoResponse); + }); + }); + + it("should not render dashboard when embedding SDK is not enabled", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": false, + }); + cy.signOut(); + + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { id: STORYBOOK_ID, viewMode: "story" }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.get("#metabase-sdk-root").within(() => { + cy.findByText("Error").should("be.visible"); + cy.findByText( + "Could not authenticate: invalid JWT URI or JWT provider did not return a valid JWT token", + ).should("be.visible"); + }); + }); + + it("should show dashboard content", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + }); + cy.signOut(); + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { id: STORYBOOK_ID, viewMode: "story" }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.wait("@getUser").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.wait("@getDashboard").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.get("#metabase-sdk-root") + .should("be.visible") + .within(() => { + cy.findByText("Embedding Sdk Test Dashboard").should("be.visible"); // dashboard title + + cy.findByText("Text text card").should("be.visible"); // text card content + + cy.wait("@dashcardQuery"); + cy.findByText("Test question card").should("be.visible"); // question card content + }); + }); + + it("should not render the SDK on non localhost sites when embedding SDK origins is not set", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + }); + cy.signOut(); + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: "http://my-site.local:6006/iframe.html", + qs: { + id: STORYBOOK_ID, + viewMode: "story", + }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.get("#metabase-sdk-root").within(() => { + cy.findByText("Error").should("be.visible"); + cy.findByText( + "Could not authenticate: invalid JWT URI or JWT provider did not return a valid JWT token", + ).should("be.visible"); + }); + }); + + it("should show dashboard content", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + "embedding-app-origins-sdk": "my-site.local:6006", + }); + cy.signOut(); + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: "http://my-site.local:6006/iframe.html", + qs: { id: STORYBOOK_ID, viewMode: "story" }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.wait("@getUser").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.wait("@getDashboard").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.get("#metabase-sdk-root") + .should("be.visible") + .within(() => { + cy.findByText("Embedding Sdk Test Dashboard").should("be.visible"); // dashboard title + + cy.findByText("Text text card").should("be.visible"); // text card content + + cy.wait("@dashcardQuery"); + cy.findByText("Test question card").should("be.visible"); // question card content + }); + }); +}); diff --git a/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js b/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js index 35e98e352c8a8..306e64707a59e 100644 --- a/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js +++ b/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js @@ -23,6 +23,9 @@ describeSDK("scenarios > embedding-sdk > static-dashboard", () => { cy.signInAsAdmin(); setTokenFeatures("all"); setupJwt(); + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + }); const textCard = getTextCardDetails({ col: 16, text: "Text text card" }); const questionCard = { diff --git a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js index 26f23a7bd5d59..911cbdf26c474 100644 --- a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js @@ -59,25 +59,14 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); cy.location("pathname").should("eq", embeddingPage); - cy.findByTestId("enable-embedding-setting").within(() => { - cy.findByText(embeddingDescription); - - cy.findByLabelText("Enable Embedding").click({ force: true }); - }); - // The URL should stay the same - cy.location("pathname").should("eq", embeddingPage); - - cy.findByTestId("enable-embedding-setting").within(() => { - cy.findByRole("checkbox").should("be.checked"); - }); - + mainPage().findByText(embeddingDescription).should("be.visible"); cy.log( "With the embedding enabled, we should now see two new sections on the main page", ); cy.log("The first section: 'Static embedding'"); - cy.findByTestId("-static-embedding-setting").within(() => { + cy.findByRole("article", { name: "Static embedding" }).within(() => { // FE unit tests are making sure this section doesn't exist when a valid token is provided, - // so we don't have to do it here usign a conditional logic + // so we don't have to do it here using conditional logic assertLinkMatchesUrl("upgrade to a paid plan", upgradeUrl); cy.findByRole("link", { name: "Manage" }) @@ -89,7 +78,15 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); cy.log("Standalone embeds page"); + // TODO: Remove this when the actual BE is implemented, this flag still controls the static embedding + // I've tried to change this but it failed like 500 BE tests. + cy.request("PUT", "/api/setting/enable-embedding-static", { + value: true, + }); mainPage().within(() => { + cy.findByLabelText("Enable Static embedding") + .click({ force: true }) + .should("be.checked"); cy.findByTestId("embedding-secret-key-setting").within(() => { cy.findByText("Embedding secret key"); cy.findByText( @@ -99,7 +96,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { cy.button("Regenerate key"); }); - cy.findByTestId("-embedded-resources-setting").within(() => { + cy.findByTestId("embedded-resources").within(() => { cy.findByText("Embedded Dashboards"); cy.findByText("No dashboards have been embedded yet."); @@ -112,7 +109,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { cy.location("pathname").should("eq", embeddingPage); cy.log("The second section: 'Interactive embedding'"); - cy.findByTestId("-interactive-embedding-setting").within(() => { + cy.findByRole("article", { name: "Interactive embedding" }).within(() => { cy.findByText("Interactive embedding"); cy.findByRole("link", { name: "Learn More" }) @@ -142,6 +139,9 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }; ["question", "dashboard"].forEach(object => { it(`should be able to publish/embed and then unpublish a ${object} without filters`, () => { + cy.request("PUT", "/api/setting/enable-embedding-static", { + value: true, + }); const embeddableObject = object === "question" ? "card" : "dashboard"; const objectName = object === "question" ? "Orders" : "Orders in a dashboard"; @@ -248,6 +248,10 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); it("should regenerate embedding token and invalidate previous embed url", () => { + cy.request("PUT", "/api/setting/enable-embedding-static", { + value: true, + }); + cy.request("PUT", `/api/card/${ORDERS_QUESTION_ID}`, { enable_embedding: true, }); @@ -311,7 +315,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); function resetEmbedding() { - updateSetting("enable-embedding", false); + updateSetting("enable-embedding-static", false); updateSetting("embedding-secret-key", null); } diff --git a/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js b/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js index a8206e1f9030e..b2bbe43ca3e6c 100644 --- a/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js +++ b/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js @@ -226,7 +226,7 @@ describe("issue 35954", () => { visitDashboard(id); openSharingMenu("Embed"); - modal().findByText("Static embed").click(); + modal().findByText("Static embedding").click(); cy.findByTestId("embedding-preview").within(() => { cy.intercept("GET", "api/preview_embed/dashboard/**").as( diff --git a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js index ee6299725f253..3a55950991b1a 100644 --- a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js @@ -37,7 +37,7 @@ import { describe("when embedding is disabled", () => { beforeEach(() => { - updateSetting("enable-embedding", false); + updateSetting("enable-embedding-static", false); }); describe("when user is admin", () => { @@ -72,7 +72,7 @@ import { describe("when public sharing is enabled", () => { beforeEach(() => { updateSetting("enable-public-sharing", true); - updateSetting("enable-embedding", true); + updateSetting("enable-embedding-static", true); }); describe("when user is admin", () => { @@ -138,7 +138,7 @@ import { describe("when public sharing is disabled", () => { beforeEach(() => { updateSetting("enable-public-sharing", false); - updateSetting("enable-embedding", true); + updateSetting("enable-embedding-static", true); }); describe("when user is admin", () => { @@ -157,8 +157,11 @@ import { cy.findByTestId("embed-menu-embed-modal-item").click(); getEmbedModalSharingPane().within(() => { - cy.findByText("Static embed").should("be.visible"); - cy.findByText("Public embed").should("be.visible"); + cy.findByText("Static embedding").should("be.visible"); + cy.findByText(/Use public embedding/).should("not.exist"); + cy.findByText("Public embeds and links are disabled.").should( + "be.visible", + ); }); }); }); @@ -190,23 +193,17 @@ describe("embed modal display", () => { }); describeEE("when the user has a paid instance", () => { - it("should display a link to the interactive embedding settings", () => { + it("should display a link to the Interactive embedding settings", () => { setTokenFeatures("all"); visitDashboard("@dashboardId"); openSharingMenu("Embed"); getEmbedModalSharingPane().within(() => { - cy.findByText("Static embed").should("be.visible"); - cy.findByText("Public embed").should("be.visible"); - cy.findByTestId("interactive-embedding-cta").within(() => { - cy.findByText("Interactive Embedding").should("be.visible"); - cy.findByText( - "Your plan allows you to use Interactive Embedding create interactive embedding experiences with drill-through and more.", - ).should("be.visible"); - cy.findByText("Set it up").should("be.visible"); - }); - cy.findByTestId("interactive-embedding-cta").click(); + cy.findByText("Static embedding").should("be.visible"); + cy.findByText("Interactive embedding").should("be.visible"); + + cy.findByText("Interactive embedding").click(); cy.url().should( "equal", @@ -224,16 +221,10 @@ describe("embed modal display", () => { openSharingMenu("Embed"); getEmbedModalSharingPane().within(() => { - cy.findByText("Static embed").should("be.visible"); - cy.findByText("Public embed").should("be.visible"); - cy.findByTestId("interactive-embedding-cta").within(() => { - cy.findByText("Interactive Embedding").should("be.visible"); - cy.findByText( - "Give your customers the full power of Metabase in your own app, with SSO, advanced permissions, customization, and more.", - ).should("be.visible"); - cy.findByText("Learn more").should("be.visible"); - }); - cy.findByTestId("interactive-embedding-cta").should( + cy.findByText("Static embedding").should("be.visible"); + cy.findByText("Interactive embedding").should("be.visible"); + + cy.findByRole("link", { name: /Interactive embedding/ }).should( "have.attr", "href", "https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedding-interactive&utm_content=static-embed-popover&source_plan=oss", @@ -361,10 +352,20 @@ describe("#39152 sharing an unsaved question", () => { }); openSharingMenu("Embed"); - cy.findByTestId("sharing-pane-public-embed-button").within(() => { - cy.findByText("Get an embed link").click(); - cy.findByTestId("copy-button").realClick(); + + modal().findByText("Get embedding code").click(); + + // mock clipboardData so that copy-to-clipboard doesn't use window.prompt, pausing the tests + cy.window().then(win => { + win.clipboardData = { + setData: (...args) => + // eslint-disable-next-line no-console + console.log("clipboardData.setData", ...args), + }; }); + + popover().findByTestId("copy-button").click(); + expectGoodSnowplowEvent({ event: "public_embed_code_copied", artifact: resource, @@ -378,10 +379,10 @@ describe("#39152 sharing an unsaved question", () => { }); openSharingMenu("Embed"); - cy.findByTestId("sharing-pane-public-embed-button").within(() => { - cy.findByText("Get an embed link").click(); - cy.button("Remove public URL").click(); - }); + modal().findByText("Get embedding code").click(); + + popover().findByText("Remove public link").click(); + expectGoodSnowplowEvent({ event: "public_link_removed", artifact: resource, diff --git a/e2e/test/scenarios/sharing/subscriptions.cy.spec.js b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js index 77ff3f2d5539e..0a1d18e4b8e2f 100644 --- a/e2e/test/scenarios/sharing/subscriptions.cy.spec.js +++ b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js @@ -52,8 +52,8 @@ describe("scenarios > dashboard > subscriptions", () => { openSharingMenu("Embed"); getEmbedModalSharingPane().within(() => { - cy.findByText("Public embed").should("be.visible"); - cy.findByText("Static embed").should("be.visible"); + cy.findByText("public embedding").should("be.visible"); + cy.findByText("Static embedding").should("be.visible"); }); }); diff --git a/enterprise/backend/src/metabase_enterprise/stale.clj b/enterprise/backend/src/metabase_enterprise/stale.clj index aeedcf684d82c..56398761e2565 100644 --- a/enterprise/backend/src/metabase_enterprise/stale.clj +++ b/enterprise/backend/src/metabase_enterprise/stale.clj @@ -44,7 +44,7 @@ [:= :moderation_review.id nil] [:= :report_card.archived false] [:<= :report_card.last_used_at (-> args :cutoff-date)] - (when (embed.settings/enable-embedding) + (when (embed.settings/some-embedding-enabled?) [:= :report_card.enable_embedding false]) (when (public-settings/enable-public-sharing) [:= :report_card.public_uuid nil]) @@ -67,7 +67,7 @@ [:= :pulse.id nil] [:= :report_dashboard.archived false] [:<= :report_dashboard.last_viewed_at (-> args :cutoff-date)] - (when (embed.settings/enable-embedding) + (when (embed.settings/some-embedding-enabled?) [:= :report_dashboard.enable_embedding false]) (when (public-settings/enable-public-sharing) [:= :report_dashboard.public_uuid nil]) diff --git a/enterprise/backend/test/metabase/embed/settings_test.clj b/enterprise/backend/test/metabase/embed/settings_test.clj index 81df0b3e4088d..4e6f4d116dd44 100644 --- a/enterprise/backend/test/metabase/embed/settings_test.clj +++ b/enterprise/backend/test/metabase/embed/settings_test.clj @@ -1,31 +1,185 @@ (ns metabase.embed.settings-test (:require + [clojure.string :as str] [clojure.test :refer :all] [metabase.analytics.snowplow-test :as snowplow-test] [metabase.embed.settings :as embed.settings] [metabase.test :as mt] [toucan2.core :as t2])) +(defn- embedding-event? + "Used to make sure we only test against embedding-events in `snowplow-test/pop-event-data-and-user-id!`." + [event] + (-> event :data (get "event") ((fn [s] (boolean (re-matches #".*embedding.*" s)))))) + (deftest enable-embedding-test - (testing "A snowplow event is sent whenever embedding is enabled or disabled" + (testing "A snowplow event is sent whenever embedding is toggled" (mt/with-test-user :crowberto (mt/with-premium-features #{:embedding} - (mt/with-temporary-setting-values [enable-embedding false - embedding-app-origin "https://example.com"] + (mt/with-temporary-setting-values [embedding-app-origins-interactive "https://example.com" + enable-embedding-interactive false] (let [embedded-dash-count (t2/count :model/Dashboard :enable_embedding true) embedded-card-count (t2/count :model/Card :enable_embedding true) expected-payload {"embedding_app_origin_set" true "number_embedded_questions" embedded-card-count "number_embedded_dashboards" embedded-dash-count}] (snowplow-test/with-fake-snowplow-collector - (embed.settings/enable-embedding! true) - (is (= [{:data - (merge expected-payload {"event" "embedding_enabled"}) + (embed.settings/enable-embedding-interactive! true) + (is (= [{:data (merge expected-payload {"event" "interactive_embedding_enabled"}) :user-id (str (mt/user->id :crowberto))}] - (-> (snowplow-test/pop-event-data-and-user-id!)))) + (filter embedding-event? (snowplow-test/pop-event-data-and-user-id!)))) - (embed.settings/enable-embedding! false) - (is (= [{:data - (merge expected-payload {"event" "embedding_disabled"}) - :user-id (str (mt/user->id :crowberto))}] - (-> (snowplow-test/pop-event-data-and-user-id!))))))))))) + (mt/with-temporary-setting-values [enable-embedding-interactive false] + (is (= [{:data + (merge expected-payload {"event" "interactive_embedding_disabled"}) + :user-id (str (mt/user->id :crowberto))}] + (filter embedding-event? (snowplow-test/pop-event-data-and-user-id!)))))))))))) + +(def ^:private other-ip "1.2.3.4:5555") + +(deftest enable-embedding-SDK-true-ignores-localhosts + (mt/with-premium-features #{:embedding :embedding-sdk} + (mt/with-temporary-setting-values [enable-embedding-sdk true] + (let [origin-value "localhost:*"] + (embed.settings/embedding-app-origins-sdk! origin-value) + (testing "All localhosty origins should be ignored, so the result should be \"localhost:*\"" + (embed.settings/embedding-app-origins-sdk! (str origin-value " localhost:8080")) + (is (= "localhost:*" (embed.settings/embedding-app-origins-sdk)))) + (testing "Normal ips are added to the list" + (embed.settings/embedding-app-origins-sdk! (str origin-value " " other-ip)) + (is (= (str "localhost:* " other-ip) (embed.settings/embedding-app-origins-sdk)))))))) + +(deftest enable-embedding-SDK-false-returns-nothing + (mt/with-premium-features #{:embedding :embedding-sdk} + (mt/with-temporary-setting-values [enable-embedding-sdk false] + (embed.settings/embedding-app-origins-sdk! "") + (let [origin-value (str "localhost:* " other-ip " " + (str/join " " (map #(str "localhost:" %) (range 1000 2000))))] + (embed.settings/embedding-app-origins-sdk! origin-value) + (is (not (and (embed.settings/enable-embedding-sdk) + (embed.settings/embedding-app-origins-sdk)))))))) + +(defn- depricated-setting-throws [f env & [reason]] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #".* deprecated.*env vars are set.*" + (f env)) reason)) + +(deftest deprecated-enabled-embedding-settings-test + ;; OK: + (is (nil? (#'embed.settings/check-enable-settings! {}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-static true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-static false :mb-enable-embedding-sdk true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-sdk true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive true :mb-enable-embedding-static false}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive false :mb-enable-embedding-sdk true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive false :mb-enable-embedding-sdk true :mb-enable-embedding-static true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding true}))) + ;; ;; Not OK: + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-static false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-sdk false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-sdk false :mb-enable-embedding-static true}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive true :mb-enable-embedding-sdk false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive false :mb-enable-embedding-static true}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive false :mb-enable-embedding-sdk true :mb-enable-embedding-static true})) + +(deftest deprecated-origin-embedding-settings-test + ;; OK: + (is (nil? (#'embed.settings/check-origins-settings! {}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin false})) + "shouldn't matter if a setting is true or false, only that it is set (not nil).") + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin nil})) + "shouldn't matter if a setting is true or false, only that it is set (not nil).") + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origins-sdk true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origins-interactive true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origins-interactive false :mb-embedding-app-origins-sdk true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin nil :mb-embedding-app-origins-interactive nil :mb-embedding-app-origins-sdk nil}))) + ;; Not OK: + (depricated-setting-throws #'embed.settings/check-origins-settings! {:mb-embedding-app-origin true :mb-embedding-app-origins-sdk false}) + (depricated-setting-throws #'embed.settings/check-origins-settings! {:mb-embedding-app-origin true :mb-embedding-app-origins-interactive false}) + (depricated-setting-throws #'embed.settings/check-origins-settings! {:mb-embedding-app-origin true :mb-embedding-app-origins-interactive true :mb-embedding-app-origins-sdk false})) + +(defn test-enabled-sync! [env expected-behavior] + (let [unsyncd-settings {:enable-embedding #_:clj-kondo/ignore (embed.settings/enable-embedding) + :enable-embedding-interactive (embed.settings/enable-embedding-interactive) + :enable-embedding-sdk (embed.settings/enable-embedding-sdk) + :enable-embedding-static (embed.settings/enable-embedding-static)}] + ;; called for side effects: + (#'embed.settings/sync-enable-settings! env) + (cond + (= expected-behavior :no-op) + (do (is (= [:no-op (:enable-embedding-interactive unsyncd-settings)] [:no-op (embed.settings/enable-embedding-interactive)])) + (is (= [:no-op (:enable-embedding-sdk unsyncd-settings)] [:no-op (embed.settings/enable-embedding-sdk)])) + (is (= [:no-op (:enable-embedding-static unsyncd-settings)] [:no-op (embed.settings/enable-embedding-static)]))) + + (= expected-behavior :sets-all-true) + (do (is (= [expected-behavior true] [:sets-all-true (embed.settings/enable-embedding-interactive)])) + (is (= [expected-behavior true] [:sets-all-true (embed.settings/enable-embedding-sdk)])) + (is (= [expected-behavior true] [:sets-all-true (embed.settings/enable-embedding-static)]))) + + (= expected-behavior :sets-all-false) + (do (is (= [expected-behavior false] [:sets-all-false (embed.settings/enable-embedding-interactive)])) + (is (= [expected-behavior false] [:sets-all-false (embed.settings/enable-embedding-sdk)])) + (is (= [expected-behavior false] [:sets-all-false (embed.settings/enable-embedding-static)]))) + + :else (throw (ex-info "Invalid expected-behavior in test-enabled-sync." {:expected-behavior expected-behavior}))))) + +(deftest sync-enabled-test + (mt/with-premium-features #{:embedding :embedding-sdk} + ;; n.b. illegal combinations will be disallowed by [[embed.settings/check-and-sync-settings-on-startup!]], so we don't test syncing for them. + (test-enabled-sync! {} :no-op) + (test-enabled-sync! {:mb-enable-embedding-static true} :no-op) + (test-enabled-sync! {:mb-enable-embedding-static false} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive true} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive false} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive true :mb-enable-embedding-static true} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive false :mb-enable-embedding-static true} :no-op) + + (test-enabled-sync! {:mb-enable-embedding true} :sets-all-true) + + (test-enabled-sync! {:mb-enable-embedding false} :sets-all-false))) + +(defn test-origin-sync! [env expected-behavior] + (testing (str "origin sync with expected-behavior: " expected-behavior) + (let [unsyncd-setting {:embedding-app-origin #_:clj-kondo/ignore (embed.settings/embedding-app-origin) + :embedding-app-origins-interactive (embed.settings/embedding-app-origins-interactive) + :embedding-app-origins-sdk (embed.settings/embedding-app-origins-sdk)}] + ;; called for side effects + (#'embed.settings/sync-origins-settings! env) + (cond + (= expected-behavior :no-op) + (do (is (= (:embedding-app-origins-interactive unsyncd-setting) + (embed.settings/embedding-app-origins-interactive))) + (is (= (#'embed.settings/add-localhost (:embedding-app-origins-sdk unsyncd-setting)) + (embed.settings/embedding-app-origins-sdk)))) + + (= expected-behavior :sets-both) + (do (is (= (:mb-embedding-app-origin env) + (embed.settings/embedding-app-origins-interactive))) + (is (= (#'embed.settings/add-localhost (:mb-embedding-app-origin env)) + (embed.settings/embedding-app-origins-sdk)))) + + :else (throw (ex-info "Invalid expected-behavior in test-origin-sync." {:expected-behavior expected-behavior})))))) + +(deftest sync-origins-test + ;; n.b. illegal combinations will be disallowed by [[embed.settings/check-and-sync-settings-on-startup!]], so we don't test syncing for them. + (mt/with-premium-features #{:embedding :embedding-sdk} + (mt/with-temporary-setting-values [enable-embedding true + enable-embedding-sdk true + enable-embedding-interactive true + enable-embedding-static true + embedding-app-origin nil + embedding-app-origins-interactive nil + embedding-app-origins-sdk nil] + (test-origin-sync! {} :no-op) + + (test-origin-sync! {:mb-embedding-app-origins-sdk other-ip} :no-op) + (test-origin-sync! {:mb-embedding-app-origins-sdk nil} :no-op) + + (test-origin-sync! {:mb-embedding-app-origins-interactive other-ip} :no-op) + (test-origin-sync! {:mb-embedding-app-origins-interactive nil} :no-op) + + (test-origin-sync! {:mb-embedding-app-origin other-ip} :sets-both)))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj index 66813da171b13..04f49a296eaea 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj @@ -162,7 +162,7 @@ (deftest dashboard-api-test (testing "/api/dashboard" (mt/with-temporary-setting-values [enable-public-sharing true - enable-embedding true] + enable-embedding-static true] (mt/with-user-in-groups [group {:name "New Group"} user [group]] @@ -207,7 +207,7 @@ (deftest action-api-test (testing "/api/action" (mt/with-temporary-setting-values [enable-public-sharing true - enable-embedding true] + enable-embedding true] (mt/with-actions-enabled (mt/with-user-in-groups [group {:name "New Group"} @@ -241,7 +241,7 @@ (deftest card-api-test (testing "/api/card" (mt/with-temporary-setting-values [enable-public-sharing true - enable-embedding true] + enable-embedding-static true] (mt/with-user-in-groups [group {:name "New Group"} user [group]] @@ -250,7 +250,7 @@ (mt/user-http-request user :get status "card/public"))) (get-embeddable-cards [user status] - (testing (format "get embeddable dashboards with %s user" (mt/user-descriptor user)) + (testing (format "get embeddable cards with %s user" (mt/user-descriptor user)) (t2.with-temp/with-temp [Card _ {:enable_embedding true}] (mt/user-http-request user :get status "card/embeddable")))) diff --git a/enterprise/backend/test/metabase_enterprise/public_settings_test.clj b/enterprise/backend/test/metabase_enterprise/public_settings_test.clj index 3cc34d6ee47ac..71e123f79e1c6 100644 --- a/enterprise/backend/test/metabase_enterprise/public_settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/public_settings_test.clj @@ -28,25 +28,24 @@ (public-settings/enable-password-login)))))))) (deftest toggle-full-app-embedding-test - (mt/discard-setting-changes [embedding-app-origin] - (testing "can't change embedding-app-origin if :embedding feature is not available" + (mt/discard-setting-changes [embedding-app-origins-interactive] + (testing "can't change embedding-app-origins-interactive if :embedding feature is not available" (mt/with-premium-features #{} (is (thrown-with-msg? clojure.lang.ExceptionInfo - #"Setting embedding-app-origin is not enabled because feature :embedding is not available" - (embed.settings/embedding-app-origin! "https://metabase.com"))) + #"Setting embedding-app-origins-interactive is not enabled because feature :embedding is not available" + (embed.settings/embedding-app-origins-interactive! "https://metabase.com"))) (testing "even if env is set, return the default value" - (mt/with-temp-env-var-value! [mb-embedding-app-origin "https://metabase.com"] - (is (nil? (embed.settings/embedding-app-origin))))))) + (mt/with-temp-env-var-value! [mb-embedding-app-origins-interactive "https://metabase.com"] + (is (nil? (embed.settings/embedding-app-origins-interactive))))))) - (testing "can change embedding-app-origin if :embedding is enabled" + (testing "can change embedding-app-origins-interactive if :embedding is enabled" (mt/with-premium-features #{:embedding} - (embed.settings/embedding-app-origin! "https://metabase.com") + (embed.settings/embedding-app-origins-interactive! "https://metabase.com") (is (= "https://metabase.com" - (embed.settings/embedding-app-origin))) - + (embed.settings/embedding-app-origins-interactive))) (testing "it works with env too" - (mt/with-temp-env-var-value! [mb-embedding-app-origin "ssh://metabase.com"] + (mt/with-temp-env-var-value! [mb-embedding-app-origins-interactive "ssh://metabase.com"] (is (= "ssh://metabase.com" - (embed.settings/embedding-app-origin))))))))) + (embed.settings/embedding-app-origins-interactive))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj index ed33b5b84a783..ad9eee7caa89b 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj @@ -346,7 +346,7 @@ (deftest jwt-token-test (testing "should return a session token when token=true" (with-jwt-default-setup! - (mt/with-temporary-setting-values [enable-embedding true] + (mt/with-temporary-setting-values [enable-embedding-static true] (let [jwt-iat-time (buddy-util/now) jwt-exp-time (+ (buddy-util/now) 3600) jwt-payload (jwt/sign {:email "rasta@metabase.com" diff --git a/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/CypressStaticDashboardWithCors.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/CypressStaticDashboardWithCors.stories.tsx new file mode 100644 index 0000000000000..14c3fcc4b59c3 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/CypressStaticDashboardWithCors.stories.tsx @@ -0,0 +1,24 @@ +import type { ComponentStory } from "@storybook/react"; + +import { StaticDashboard } from "embedding-sdk"; +import { CommonSdkStoryCorsWrapper } from "embedding-sdk/test/CommonSdkCorsStoryWrapper"; + +const DASHBOARD_ID = (window as any).DASHBOARD_ID || "1"; + +export default { + title: "EmbeddingSDK/CypressStaticDashboardWithCors", + component: StaticDashboard, + parameters: { + layout: "fullscreen", + }, + decorators: [CommonSdkStoryCorsWrapper], +}; + +const Template: ComponentStory = args => { + return ; +}; + +export const Default = Template.bind({}); +Default.args = { + dashboardId: DASHBOARD_ID, +}; diff --git a/enterprise/frontend/src/embedding-sdk/test/CommonSdkCorsStoryWrapper.tsx b/enterprise/frontend/src/embedding-sdk/test/CommonSdkCorsStoryWrapper.tsx new file mode 100644 index 0000000000000..703a5ee320d6a --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/test/CommonSdkCorsStoryWrapper.tsx @@ -0,0 +1,17 @@ +import type { Story } from "@storybook/react"; + +import { MetabaseProvider, type SDKConfig } from "embedding-sdk"; + +const METABASE_INSTANCE_URL = + (window as any).METABASE_INSTANCE_URL || "http://localhost:3000"; + +const DEFAULT_CONFIG: SDKConfig = { + metabaseInstanceUrl: METABASE_INSTANCE_URL, + jwtProviderUri: `${METABASE_INSTANCE_URL}/sso/metabase`, +}; + +export const CommonSdkStoryCorsWrapper = (Story: Story) => ( + + + +); diff --git a/enterprise/frontend/src/metabase-enterprise/embedding-sdk/index.js b/enterprise/frontend/src/metabase-enterprise/embedding-sdk/index.js new file mode 100644 index 0000000000000..25382cec7e042 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/embedding-sdk/index.js @@ -0,0 +1,6 @@ +import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins"; +import { hasPremiumFeature } from "metabase-enterprise/settings"; + +if (hasPremiumFeature("embedding_sdk")) { + PLUGIN_EMBEDDING_SDK.isEnabled = () => true; +} diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx index 8b329d481be93..36e86d1535d65 100644 --- a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx +++ b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx @@ -23,6 +23,7 @@ export const EmbeddingAppSameSiteCookieDescription = () => { const embeddingAuthorizedOrigins = useSetting("embedding-app-origin"); const shouldDisplayNote = + embeddingAuthorizedOrigins && embeddingSameSiteCookieSetting !== "none" && authorizedOriginsContainsNonInstanceDomain(embeddingAuthorizedOrigins); diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx index da96a3029422a..dab2f3529e7a2 100644 --- a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx +++ b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx @@ -1,8 +1,28 @@ import { useState } from "react"; +import { t } from "ttag"; import { Button, Group, Icon, Menu, Text } from "metabase/ui"; import type { SessionCookieSameSite } from "metabase-types/api"; +const SAME_SITE_OPTIONS: Options[] = [ + { + value: "lax", + name: t`Lax (default)`, + description: t`Allows cookies to be sent when a user is navigating to the origin site from an external site (like when following a link).`, + }, + { + value: "strict", + name: t`Strict (not recommended)`, + // eslint-disable-next-line no-literal-metabase-strings -- Metabase settings + description: t`Never allows cookies to be sent on a cross-site request. Warning: this will prevent users from following external links to Metabase.`, + }, + { + value: "none", + name: t`None`, + description: t`Allows all cross-site requests. Incompatible with most Safari and iOS-based browsers.`, + }, +]; + interface Options { value: SessionCookieSameSite; name: string; @@ -14,19 +34,18 @@ interface SameSiteSelectWidgetProps { setting: { key: "session-cookie-samesite"; value?: SessionCookieSameSite; - defaultValue: SessionCookieSameSite; - options: Options[]; }; } +const DEFAULT_SAME_SITE_VALUE = "lax"; export function SameSiteSelectWidget({ setting, onChange, }: SameSiteSelectWidgetProps) { const [opened, setOpened] = useState(false); - const selectedValue = setting.value ?? setting.defaultValue; - const selectedOption = setting.options.find( + const selectedValue = setting.value ?? DEFAULT_SAME_SITE_VALUE; + const selectedOption = SAME_SITE_OPTIONS.find( ({ value }) => value === selectedValue, ); @@ -47,7 +66,7 @@ export function SameSiteSelectWidget({ - {setting.options.map(({ value, name, description }) => ( + {SAME_SITE_OPTIONS.map(({ value, name, description }) => ( onChange(value)}> {name} {description} diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/components/InteractiveEmbeddingSettings.tsx b/enterprise/frontend/src/metabase-enterprise/embedding/components/InteractiveEmbeddingSettings.tsx new file mode 100644 index 0000000000000..d97180628d8a2 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/embedding/components/InteractiveEmbeddingSettings.tsx @@ -0,0 +1,95 @@ +import { t } from "ttag"; + +import type { AdminSettingComponentProps } from "metabase/admin/settings/components/EmbeddingSettings/types"; +import SettingHeader from "metabase/admin/settings/components/SettingHeader"; +import { SetByEnvVarWrapper } from "metabase/admin/settings/components/SettingsSetting"; +import { SwitchWithSetByEnvVar } from "metabase/admin/settings/components/widgets/EmbeddingOption/SwitchWithSetByEnvVar"; +import { SettingTextInput } from "metabase/admin/settings/components/widgets/SettingTextInput"; +import { useMergeSetting } from "metabase/common/hooks"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import { Box, Stack } from "metabase/ui"; +import type { SessionCookieSameSite } from "metabase-types/api"; + +import { EmbeddingAppOriginDescription } from "./EmbeddingAppOriginDescription"; +import { + EmbeddingAppSameSiteCookieDescription, + SameSiteSelectWidget, +} from "./EmbeddingAppSameSiteCookieDescription"; + +const INTERACTIVE_EMBEDDING_ORIGINS_SETTING = { + key: "embedding-app-origins-interactive", + display_name: t`Authorized origins`, + description: , + placeholder: "https://*.example.com", +} as const; + +const SAME_SITE_SETTING = { + key: "session-cookie-samesite", + display_name: t`SameSite cookie setting`, + description: , + widget: SameSiteSelectWidget, +} as const; + +export function InteractiveEmbeddingSettings({ + updateSetting, +}: AdminSettingComponentProps) { + function handleToggleInteractiveEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-interactive" }, value); + } + + const interactiveEmbeddingOriginsSetting = useMergeSetting( + INTERACTIVE_EMBEDDING_ORIGINS_SETTING, + ); + + function handleChangeInteractiveEmbeddingOrigins(value: string | null) { + updateSetting({ key: interactiveEmbeddingOriginsSetting.key }, value); + } + + const sameSiteSetting = useMergeSetting(SAME_SITE_SETTING); + + function handleChangeSameSite(value: SessionCookieSameSite) { + updateSetting({ key: sameSiteSetting.key }, value); + } + + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/index.js b/enterprise/frontend/src/metabase-enterprise/embedding/index.js index 7bcab6a377024..b0b481ebb6479 100644 --- a/enterprise/frontend/src/metabase-enterprise/embedding/index.js +++ b/enterprise/frontend/src/metabase-enterprise/embedding/index.js @@ -1,71 +1,14 @@ -import { t } from "ttag"; - -import { - PLUGIN_ADMIN_SETTINGS_UPDATES, - PLUGIN_EMBEDDING, -} from "metabase/plugins"; +import { PLUGIN_ADMIN_SETTINGS, PLUGIN_EMBEDDING } from "metabase/plugins"; import { isInteractiveEmbeddingEnabled } from "metabase-enterprise/embedding/selectors"; import { hasPremiumFeature } from "metabase-enterprise/settings"; -import { EmbeddingAppOriginDescription } from "./components/EmbeddingAppOriginDescription"; -import { - EmbeddingAppSameSiteCookieDescription, - SameSiteSelectWidget, -} from "./components/EmbeddingAppSameSiteCookieDescription"; - -const SLUG = "embedding-in-other-applications/full-app"; +import { InteractiveEmbeddingSettings } from "./components/InteractiveEmbeddingSettings"; if (hasPremiumFeature("embedding")) { PLUGIN_EMBEDDING.isEnabled = () => true; PLUGIN_EMBEDDING.isInteractiveEmbeddingEnabled = isInteractiveEmbeddingEnabled; - PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => { - return { - ...sections, - [SLUG]: { - ...sections[SLUG], - settings: [ - ...sections[SLUG]["settings"], - { - key: "embedding-app-origin", - display_name: t`Authorized origins`, - description: , - placeholder: "https://*.example.com", - type: "string", - getHidden: (_, derivedSettings) => - !derivedSettings["enable-embedding"], - }, - { - key: "session-cookie-samesite", - display_name: t`SameSite cookie setting`, - description: , - type: "select", - options: [ - { - value: "lax", - name: t`Lax (default)`, - description: t`Allows cookies to be sent when a user is navigating to the origin site from an external site (like when following a link).`, - }, - { - value: "strict", - name: t`Strict (not recommended)`, - // eslint-disable-next-line no-literal-metabase-strings -- Metabase settings - description: t`Never allows cookies to be sent on a cross-site request. Warning: this will prevent users from following external links to Metabase.`, - }, - { - value: "none", - name: t`None`, - description: t`Allows all cross-site requests. Incompatible with most Safari and iOS-based browsers.`, - }, - ], - defaultValue: "lax", - widget: SameSiteSelectWidget, - getHidden: (_, derivedSettings) => - !derivedSettings["enable-embedding"], - }, - ], - }, - }; - }); + PLUGIN_ADMIN_SETTINGS.InteractiveEmbeddingSettings = + InteractiveEmbeddingSettings; } diff --git a/enterprise/frontend/src/metabase-enterprise/plugins.js b/enterprise/frontend/src/metabase-enterprise/plugins.js index cc278b0831a10..77e73e14de81c 100644 --- a/enterprise/frontend/src/metabase-enterprise/plugins.js +++ b/enterprise/frontend/src/metabase-enterprise/plugins.js @@ -16,6 +16,7 @@ import "./collections"; import "./content_verification"; import "./whitelabel"; import "./embedding"; +import "./embedding-sdk"; import "./snippets"; import "./sharing"; import "./moderation"; diff --git a/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx b/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx index 7a47ee8df53b6..86b84897c2d5e 100644 --- a/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx +++ b/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx @@ -4,7 +4,7 @@ import { t } from "ttag"; import _ from "underscore"; import { AuthTabs } from "metabase/admin/settings/components/AuthTabs"; -import SettingToggle from "metabase/admin/settings/components/widgets/SettingToggle"; +import { SettingToggle } from "metabase/admin/settings/components/widgets/SettingToggle"; import type { SettingElement } from "metabase/admin/settings/types"; import { useSetting } from "metabase/common/hooks"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; diff --git a/frontend/src/metabase-types/analytics/embed-share.ts b/frontend/src/metabase-types/analytics/embed-share.ts index 979772b888f2d..c2eeae2d72ade 100644 --- a/frontend/src/metabase-types/analytics/embed-share.ts +++ b/frontend/src/metabase-types/analytics/embed-share.ts @@ -10,15 +10,24 @@ type ValidateEvent< Record, never>, > = T; +type EmbeddingEventName = + | "embedding" + | "sdk_embedding" + | "interactive_embedding" + | "static_embedding"; + +type EmbeddingEventEnabled = `${EmbeddingEventName}_enabled`; +type EmbeddingEventDisabled = `${EmbeddingEventName}_disabled`; + export type EmbeddingEnabledEvent = ValidateEvent<{ - event: "embedding_enabled"; + event: EmbeddingEventEnabled; authorized_origins_set: boolean; number_embedded_questions: number; number_embedded_dashboards: number; }>; export type EmbeddingDisabledEvent = ValidateEvent<{ - event: "embedding_disabled"; + event: EmbeddingEventDisabled; authorized_origins_set: boolean; number_embedded_questions: number; number_embedded_dashboards: number; diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 82a2c255c09fb..e65860a18277d 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -5,6 +5,7 @@ import type { EngineSource, FontFile, SettingDefinition, + SettingKey, Settings, TokenFeatures, TokenStatus, @@ -129,13 +130,14 @@ export const createMockTokenFeatures = ( ...opts, }); -export const createMockSettingDefinition = ( - opts?: Partial, -): SettingDefinition => ({ - key: "key", +export const createMockSettingDefinition = < + Key extends SettingKey = SettingKey, +>( + opts: SettingDefinition, +): SettingDefinition => ({ env_name: "", is_env_setting: false, - value: null, + value: opts.value, ...opts, }); @@ -169,7 +171,12 @@ export const createMockSettings = ( "email-smtp-username": null, "email-smtp-password": null, "embedding-app-origin": "", + "embedding-app-origins-sdk": "", + "embedding-app-origins-interactive": "", "enable-embedding": false, + "enable-embedding-static": false, + "enable-embedding-sdk": false, + "enable-embedding-interactive": false, "enable-enhancements?": false, "enable-nested-queries": true, "expand-browse-in-nav": true, @@ -212,6 +219,7 @@ export const createMockSettings = ( "report-timezone-long": "Europe/London", "saml-configured": false, "saml-enabled": false, + "saml-identity-provider-uri": null, "scim-enabled": false, "scim-base-url": "http://localhost:3000/api/ee/scim/v2/", "snowplow-url": "", diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index c1cbc3e89e6ce..d7ee125d6e41b 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + export interface FormattingSettings { "type/Temporal"?: DateFormattingSettings; "type/Number"?: NumberFormattingSettings; @@ -184,17 +186,17 @@ export type PasswordComplexity = { export type SessionCookieSameSite = "lax" | "strict" | "none"; -export type UpdateChannel = "latest" | "beta" | "nightly"; - -export interface SettingDefinition { - key: string; +export interface SettingDefinition { + key: Key; env_name?: string; - is_env_setting: boolean; - value?: unknown; - default?: unknown; - description?: string; + is_env_setting?: boolean; + value?: SettingValue; + default?: SettingValue; + description?: string | ReactNode | null; } +export type UpdateChannel = "latest" | "beta" | "nightly"; + export interface OpenAiModel { id: string; owned_by: string; @@ -216,6 +218,9 @@ interface InstanceSettings { "email-smtp-username": string | null; "email-smtp-password": string | null; "enable-embedding": boolean; + "enable-embedding-static": boolean; + "enable-embedding-sdk": boolean; + "enable-embedding-interactive": boolean; "enable-nested-queries": boolean; "enable-public-sharing": boolean; "enable-xrays": boolean; @@ -254,6 +259,7 @@ interface AdminSettings { "premium-embedding-token": string | null; "saml-configured"?: boolean; "saml-enabled"?: boolean; + "saml-identity-provider-uri": string | null; "other-sso-enabled?"?: boolean; // yes the question mark is in the variable name "show-database-syncing-modal": boolean; "token-status": TokenStatus | null; @@ -295,7 +301,9 @@ interface PublicSettings { "custom-homepage-dashboard": number | null; "ee-ai-features-enabled"?: boolean; "email-configured?": boolean; - "embedding-app-origin": string; + "embedding-app-origin": string | null; + "embedding-app-origins-sdk": string | null; + "embedding-app-origins-interactive": string | null; "enable-enhancements?": boolean; "enable-password-login": boolean; engines: Record; @@ -355,4 +363,4 @@ export type Settings = InstanceSettings & export type SettingKey = keyof Settings; -export type SettingValue = Settings[SettingKey]; +export type SettingValue = Settings[Key]; diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx index 1cc3353becf68..bb7a94a42c9b3 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx @@ -88,7 +88,7 @@ class SettingsEditor extends Component { * @param {function} [options.onChanged] - callback fired after the setting has been updated * @param {function} [options.onError] - callback fired after the setting has failed to update */ - updateSetting = async (setting, newValue, options) => { + handleUpdateSetting = async (setting, newValue, options) => { const { settingValues, updateSetting, reloadSettings, dispatch } = this.props; @@ -181,7 +181,7 @@ class SettingsEditor extends Component { elements={activeSection.settings} settingValues={settingValues} derivedSettingValues={derivedSettingValues} - updateSetting={this.updateSetting.bind(this)} + updateSetting={this.handleUpdateSetting.bind(this)} onChangeSetting={this.handleChangeSetting.bind(this)} reloadSettings={this.props.reloadSettings} /> @@ -193,7 +193,7 @@ class SettingsEditor extends Component { settingElements={activeSection.settings} settingValues={settingValues} derivedSettingValues={derivedSettingValues} - updateSetting={this.updateSetting.bind(this)} + updateSetting={this.handleUpdateSetting.bind(this)} onChangeSetting={this.handleChangeSetting.bind(this)} reloadSettings={this.props.reloadSettings} /> diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx index 56bbc40e6128f..189c24d289328 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx @@ -1,43 +1,12 @@ -import userEvent from "@testing-library/user-event"; - import { screen } from "__support__/ui"; import { createMockSettingDefinition, createMockSettings, } from "metabase-types/api/mocks"; -import { EMAIL_URL, FULL_APP_EMBEDDING_URL, setup } from "./setup"; +import { EMAIL_URL, setup } from "./setup"; describe("SettingsEditor", () => { - describe("full-app embedding", () => { - it("should show info about interactive embedding", async () => { - await setup({ - settings: [createMockSettingDefinition({ key: "enable-embedding" })], - settingValues: createMockSettings({ "enable-embedding": true }), - }); - - await userEvent.click(screen.getByText("Embedding")); - await userEvent.click(screen.getByText("Interactive embedding")); - expect(screen.queryByText("Authorized origins")).not.toBeInTheDocument(); - expect( - screen.queryByText("SameSite cookie setting"), - ).not.toBeInTheDocument(); - }); - - it("should allow visiting the full-app embedding page even if embedding is not enabled", async () => { - await setup({ - settings: [createMockSettingDefinition({ key: "enable-embedding" })], - settingValues: createMockSettings({ "enable-embedding": false }), - initialRoute: FULL_APP_EMBEDDING_URL, - }); - - expect( - screen.getByText(/Embed dashboards, questions/), - ).toBeInTheDocument(); - expect(screen.getByText("Interactive embedding")).toBeInTheDocument(); - }); - }); - describe("subscription allowed domains", () => { it("should not be visible", async () => { await setup({ diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx index 3a9c993a2fd82..486a04415dc23 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event"; -import { screen } from "__support__/ui"; +import { screen, within } from "__support__/ui"; import { createMockSettingDefinition, createMockSettings, @@ -115,5 +115,11 @@ describe("SettingsEditor", () => { }); const goToInteractiveEmbeddingSettings = async () => { - await userEvent.click(screen.getByText("Configure")); + await userEvent.click( + within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ).getByText("Configure"), + ); }; diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx index 8be967b4ded61..38e9403fc7216 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx @@ -1,54 +1,438 @@ -import { act, screen } from "__support__/ui"; +import userEvent from "@testing-library/user-event"; + +import { act, screen, within } from "__support__/ui"; +import { + createMockSettingDefinition, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; + +import { FULL_APP_EMBEDDING_URL, setup } from "../setup"; import { embeddingSettingsUrl, - getQuickStartLink, - goToStaticEmbeddingSettings, + getInteractiveEmbeddingQuickStartLink, interactiveEmbeddingSettingsUrl, setupEmbedding, staticEmbeddingSettingsUrl, } from "./setup"; +import type { History } from "./types"; describe("[OSS] embedding settings", () => { - describe("when the embedding is disabled", () => { + describe("when static embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { "enable-embedding-static": false }, + }) + ).history; + }); + describe("static embedding", () => { - it("should not allow going to static embedding settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", + ); + }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).not.toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is disabled", () => { + beforeEach(async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-sdk": false }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Disabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).not.toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); + }); + }); + + describe("when interactive embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { + "enable-embedding-interactive": false, + }, + }) + ).history; + }); - expect(screen.getByRole("button", { name: "Manage" })).toBeDisabled(); + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).toBeDisabled(); + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { act(() => { - history.push(staticEmbeddingSettingsUrl); + history.push(interactiveEmbeddingSettingsUrl); }); expect(history.getCurrentLocation().pathname).toEqual( embeddingSettingsUrl, ); }); + }); + }); - it("should prompt to upgrade to remove the Powered by text", async () => { + describe("when static embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + settingValues: { "enable-embedding-static": true }, + }) + ).history; + }); - expect(screen.getByText("upgrade to a paid plan")).toBeInTheDocument(); + describe("static embedding", () => { + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: "upgrade to a paid plan" }), + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), ).toHaveProperty( "href", "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", ); }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is enabled", () => { + beforeEach(async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-sdk": true }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); }); + }); + + describe("when interactive embedding is enabled", () => { + let history: History; + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { "enable-embedding-interactive": true }, + }) + ).history; + }); describe("interactive embedding", () => { - it("should not allow going to interactive settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeDisabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { + expect( + screen.queryByRole("button", { name: "Configure" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Learn More" }), + ).toBeInTheDocument(); act(() => { history.push(interactiveEmbeddingSettingsUrl); @@ -58,79 +442,230 @@ describe("[OSS] embedding settings", () => { embeddingSettingsUrl, ); }); + }); + }); - it("should have a learn more button for interactive embedding", async () => { - await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should link to quickstart for interactive embedding", async () => { + await setupEmbedding({ + settingValues: { + "enable-embedding": false, + version: { tag: "v0.49.3" }, + }, + }); + expect(getInteractiveEmbeddingQuickStartLink()).toBeInTheDocument(); + expect(getInteractiveEmbeddingQuickStartLink()).toHaveProperty( + "href", + "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should redirect users back to embedding settings page when visiting the full-app embedding page when embedding is not enabled", async () => { + await setup({ + settings: [createMockSettingDefinition({ key: "enable-embedding" })], + settingValues: createMockSettings({ "enable-embedding": false }), + tokenFeatures: createMockTokenFeatures({ + embedding: false, + embedding_sdk: false, + }), + hasEnterprisePlugins: false, + initialRoute: FULL_APP_EMBEDDING_URL, + }); + + expect(screen.getByText("Static embedding")).toBeInTheDocument(); + expect(screen.getByText("Embedded analytics SDK")).toBeInTheDocument(); + expect(screen.getByText("Interactive embedding")).toBeInTheDocument(); + }); + + describe("self-hosted (OSS)", () => { + beforeEach(async () => { + await setupEmbedding({ + isHosted: false, + settingValues: { "is-hosted?": false }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + }); + + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, switch Metabase binaries, upgrade to Metabase Pro and implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); expect( - screen.getByRole("link", { name: "Learn More" }), + withinApiKeyBanner.getByRole("link", { + name: "switch Metabase binaries", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/docs/latest/paid-features/activating-the-enterprise-edition.html?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", + ); + expect( + withinApiKeyBanner.getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", + ); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", + ); + }); + + it("should show quick start section", () => { + expect( + screen.getByText("Try Embedded analytics SDK"), + ).toBeInTheDocument(); + expect( + screen.getByText("Use the SDK with API keys for development."), ).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Learn More" })).toHaveProperty( + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( "href", - "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", ); }); - it("should link to quickstart for interactive embedding", async () => { - await setupEmbedding({ - settingValues: { - "enable-embedding": false, - version: { tag: "v0.49.3" }, - }, - }); - expect(getQuickStartLink()).toBeInTheDocument(); - expect(getQuickStartLink()).toHaveProperty( + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + const corsSettingDescription = screen.getByText( + /Try out the SDK on localhost. To enable other sites/, + ); + expect(corsSettingDescription).toHaveTextContent( + "Try out the SDK on localhost. To enable other sites, upgrade to Metabase Pro and Enter the origins for the websites or apps where you want to allow SDK embedding.", + ); + + expect( + within(corsSettingDescription).getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( "href", - "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", ); }); - it("should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", async () => { - await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); expect( - screen.getByText("offer multi-tenant, self-service analytics"), + within(documentationText).getByRole("link", { + name: "documentation", + }), ).toHaveProperty( "href", - "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", ); }); + + it("should not show version pinning section", () => { + expect(screen.queryByText("Version pinning")).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Request version pinning" }), + ).not.toBeInTheDocument(); + }); }); }); - describe("when the embedding is enabled", () => { - it("should allow going to static embedding settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": true }, - }); - await goToStaticEmbeddingSettings(); + describe("when environment variables are set", () => { + it("should show `Set by environment variable` when the enable-embedding-static is an env var", async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-static": true }, + isEnvVar: true, + }); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("Set via environment variable"), + ).toBeVisible(); }); - it("should not allow going to interactive embedding settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": true }, + it("should show `Set by environment variable` when the enable-embedding-sdk is an env var", async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-sdk": true }, + isEnvVar: true, }); + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); expect( - screen.queryByRole("button", { name: "Configure" }), - ).not.toBeInTheDocument(); + withinEmbeddingSdkCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-interactive is an env var", async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-interactive": true }, + isEnvVar: true, + }); + + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); expect( - screen.getByRole("link", { name: "Learn More" }), - ).toBeInTheDocument(); + withinInteractiveEmbeddingCard.getByText( + "Set via environment variable", + ), + ).toBeVisible(); + }); - act(() => { - history.push(interactiveEmbeddingSettingsUrl); + it("should show `Set by environment variable` when the embedding-app-origins-sdk is an env var", async () => { + await setupEmbedding({ + settingValues: { "embedding-app-origins-sdk": null }, + isEnvVar: true, }); - expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), ); + + await userEvent.click(withinEmbeddingSdkCard.getByText("Try it out")); + + expect(screen.getByText(/this has been set by the/i)).toBeInTheDocument(); + expect( + screen.getByText(/embedding-app-origins-sdk/i), + ).toBeInTheDocument(); + expect(screen.getByText(/environment variable/i)).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx index 625029589aef0..f2e65e46392b0 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx @@ -1,14 +1,23 @@ -import { act, screen } from "__support__/ui"; +import userEvent from "@testing-library/user-event"; + +import { act, screen, within } from "__support__/ui"; +import { + createMockSettingDefinition, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; + +import { FULL_APP_EMBEDDING_URL, setup } from "../setup"; import type { SetupOpts } from "./setup"; import { embeddingSettingsUrl, - getQuickStartLink, - goToStaticEmbeddingSettings, + getInteractiveEmbeddingQuickStartLink, interactiveEmbeddingSettingsUrl, setupEmbedding, staticEmbeddingSettingsUrl, } from "./setup"; +import type { History } from "./types"; const setupEnterprise = (opts?: SetupOpts) => { return setupEmbedding({ @@ -19,45 +28,419 @@ const setupEnterprise = (opts?: SetupOpts) => { }; describe("[EE, no token] embedding settings", () => { - describe("when the embedding is disabled", () => { + describe("when static embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { "enable-embedding-static": false }, + }) + ).history; + }); + describe("static embedding", () => { - it("should not allow going to static embedding settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", + ); + }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).not.toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is disabled", () => { + beforeEach(async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-sdk": false }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Disabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).not.toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); + }); + }); + + describe("when interactive embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEnterprise({ + settingValues: { "enable-embedding-interactive": false }, + }) + ).history; + }); + + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); - expect(screen.getByRole("button", { name: "Manage" })).toBeDisabled(); + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).toBeDisabled(); + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { act(() => { - history.push(staticEmbeddingSettingsUrl); + history.push(interactiveEmbeddingSettingsUrl); }); expect(history.getCurrentLocation().pathname).toEqual( embeddingSettingsUrl, ); }); + }); + }); - it("should prompt to upgrade to remove the Powered by text", async () => { + describe("when static embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + settingValues: { "enable-embedding-static": true }, + }) + ).history; + }); + + describe("static embedding", () => { + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); - expect(screen.getByText("upgrade to a paid plan")).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: "upgrade to a paid plan" }), + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), ).toHaveProperty( "href", "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", ); }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is enabled", () => { + beforeEach(async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-sdk": true }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); + }); + }); + + describe("when interactive embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEnterprise({ + settingValues: { "enable-embedding-interactive": true }, + }) + ).history; }); describe("interactive embedding", () => { - it("should not allow going to interactive settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeDisabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { + expect( + screen.queryByRole("button", { name: "Configure" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Learn More" }), + ).toBeInTheDocument(); act(() => { history.push(interactiveEmbeddingSettingsUrl); @@ -67,80 +450,230 @@ describe("[EE, no token] embedding settings", () => { embeddingSettingsUrl, ); }); + }); + }); - it("should have a learn more button for interactive embedding", async () => { - await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should link to quickstart for interactive embedding", async () => { + await setupEnterprise({ + settingValues: { + "enable-embedding": false, + version: { tag: "v0.49.3" }, + }, + }); + expect(getInteractiveEmbeddingQuickStartLink()).toBeInTheDocument(); + expect(getInteractiveEmbeddingQuickStartLink()).toHaveProperty( + "href", + "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should redirect users back to embedding settings page when visiting the full-app embedding page when embedding is not enabled", async () => { + await setup({ + settings: [createMockSettingDefinition({ key: "enable-embedding" })], + settingValues: createMockSettings({ "enable-embedding": false }), + tokenFeatures: createMockTokenFeatures({ + embedding: false, + embedding_sdk: false, + }), + hasEnterprisePlugins: true, + initialRoute: FULL_APP_EMBEDDING_URL, + }); + + expect(screen.getByText("Static embedding")).toBeInTheDocument(); + expect(screen.getByText("Embedded analytics SDK")).toBeInTheDocument(); + expect(screen.getByText("Interactive embedding")).toBeInTheDocument(); + }); + + describe("cloud (Cloud starter)", () => { + beforeEach(async () => { + await setupEnterprise({ + isHosted: true, + settingValues: { "is-hosted?": true }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + }); + + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, upgrade to Metabase Pro and implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); expect( - screen.getByRole("link", { name: "Learn More" }), + withinApiKeyBanner.getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", + ); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", + ); + }); + + it("should show quick start section", () => { + expect( + screen.getByText("Try Embedded analytics SDK"), + ).toBeInTheDocument(); + expect( + screen.getByText("Use the SDK with API keys for development."), ).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Learn More" })).toHaveProperty( + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( "href", - "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", ); }); - it("should link to quickstart for interactive embedding", async () => { - await setupEnterprise({ - settingValues: { - "enable-embedding": false, - version: { tag: "v1.49.3" }, - }, - }); - expect(getQuickStartLink()).toBeInTheDocument(); - expect(getQuickStartLink()).toHaveProperty( + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + const corsSettingDescription = screen.getByText( + /Try out the SDK on localhost. To enable other sites/, + ); + expect(corsSettingDescription).toHaveTextContent( + "Try out the SDK on localhost. To enable other sites, upgrade to Metabase Pro and Enter the origins for the websites or apps where you want to allow SDK embedding.", + ); + + expect( + within(corsSettingDescription).getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( "href", - "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", ); }); - it("should link to https://www.metabase.com/blog/why-full-app-embedding", async () => { - await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); expect( - screen.getByText("offer multi-tenant, self-service analytics"), + within(documentationText).getByRole("link", { + name: "documentation", + }), ).toHaveProperty( "href", - "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", ); }); + + it("should not show version pinning section", () => { + expect(screen.queryByText("Version pinning")).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Request version pinning" }), + ).not.toBeInTheDocument(); + }); }); }); - describe("when the embedding is enabled", () => { - it("should allow going to static embedding settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": true }, + + describe("when environment variables are set", () => { + it("should show `Set by environment variable` when the embedding-app-origins-sdk is an env var", async () => { + await setupEnterprise({ + settingValues: { "embedding-app-origins-sdk": null }, + isEnvVar: true, }); - await goToStaticEmbeddingSettings(); + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + await userEvent.click(withinEmbeddingSdkCard.getByText("Try it out")); + + const withinEnvVarMessage = within( + screen.getByTestId("setting-env-var-message"), + ); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + expect( + withinEnvVarMessage.getByText(/this has been set by the/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/embedding-app-origins-sdk/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/environment variable/i), + ).toBeInTheDocument(); }); - it("should not allow going to interactive embedding settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": true }, + it("should show `Set by environment variable` when the enable-embedding-static is an env var", async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-static": true }, + isEnvVar: true, }); + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); expect( - screen.queryByRole("button", { name: "Configure" }), - ).not.toBeInTheDocument(); + withinStaticEmbeddingCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-sdk is an env var", async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-sdk": true }, + isEnvVar: true, + }); + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); expect( - screen.getByRole("link", { name: "Learn More" }), - ).toBeInTheDocument(); + withinEmbeddingSdkCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); - act(() => { - history.push(interactiveEmbeddingSettingsUrl); + it("should show `Set by environment variable` when the enable-embedding-interactive is an env var", async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-interactive": true }, + isEnvVar: true, }); - expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), ); + expect( + withinInteractiveEmbeddingCard.getByText( + "Set via environment variable", + ), + ).toBeVisible(); }); }); }); diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx index 4282048323ced..bb7c0a54ad2d6 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx @@ -1,17 +1,26 @@ -import { act, screen } from "__support__/ui"; +import userEvent from "@testing-library/user-event"; +import fetchMock from "fetch-mock"; + +import { screen, within } from "__support__/ui"; +import { + createMockSettingDefinition, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; + +import { FULL_APP_EMBEDDING_URL, setup } from "../setup"; import type { SetupOpts } from "./setup"; import { - embeddingSettingsUrl, - getQuickStartLink, - goToInteractiveEmbeddingSettings, - goToStaticEmbeddingSettings, + getInteractiveEmbeddingQuickStartLink, interactiveEmbeddingSettingsUrl, setupEmbedding, staticEmbeddingSettingsUrl, } from "./setup"; +import type { History } from "./types"; const setupPremium = (opts?: SetupOpts) => { + fetchMock.put("path:/api/setting/enable-embedding-interactive", 204); return setupEmbedding({ ...opts, hasEnterprisePlugins: true, @@ -20,92 +29,793 @@ const setupPremium = (opts?: SetupOpts) => { }; describe("[EE, with token] embedding settings", () => { - describe("when the embedding is disabled", () => { + describe("when static embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupPremium({ + settingValues: { "enable-embedding-static": false }, + }) + ).history; + }); + describe("static embedding", () => { - it("should not allow going to static embedding settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + }); + + it("should not prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.queryByText("upgrade to a paid plan"), + ).not.toBeInTheDocument(); + }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).not.toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is disabled", () => { + beforeEach(async () => { + await setupPremium({ + settingValues: { + "enable-embedding-sdk": false, + "embedding-app-origins-sdk": "metabase-sdk.com", + }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Disabled")).toBeEnabled(); + }); + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).not.toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); expect( - await screen.findByRole("button", { name: "Manage" }), + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), ).toBeDisabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toHaveValue("metabase-sdk.com"); + }); + }); + }); - act(() => { - history.push(staticEmbeddingSettingsUrl); - }); + describe("when interactive embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupPremium({ + settingValues: { + "enable-embedding-interactive": false, + "embedding-app-origins-interactive": "localhost:9999", + "session-cookie-samesite": "strict", + }, + }) + ).history; + }); + + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings", + ); + }); + + it("should allow access to interactive embedding settings page", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + expect( + withinInteractiveEmbeddingCard.queryByRole("link", { + name: "Learn More", + }), + ).not.toBeInTheDocument(); + + await userEvent.click( + withinInteractiveEmbeddingCard.getByRole("button", { + name: "Configure", + }), + ); + + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).not.toBeChecked(); + await userEvent.click( + screen.getByLabelText("Enable Interactive embedding"), + ); + + expect(screen.getByLabelText("Authorized origins")).toBeEnabled(); + expect(screen.getByLabelText("Authorized origins")).toHaveValue( + "localhost:9999", + ); + + expect(screen.getByText("SameSite cookie setting")).toBeInTheDocument(); + expect( + screen.getByText("Strict (not recommended)"), + ).toBeInTheDocument(); expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + interactiveEmbeddingSettingsUrl, ); }); + }); + }); - it("should not prompt to upgrade to remove the Powered by text", async () => { + describe("when static embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupPremium({ - settingValues: { "enable-embedding": false }, - }); + settingValues: { "enable-embedding-static": true }, + }) + ).history; + }); + + describe("static embedding", () => { + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); expect( - screen.queryByText("upgrade to a paid plan"), + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + }); + + it("should not prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.queryByText("upgrade to a paid plan"), ).not.toBeInTheDocument(); }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); }); + }); - describe("interactive embedding", () => { - it("should not allow going to interactive settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": false }, - }); + describe("when embedding SDK is enabled", () => { + beforeEach(async () => { + await setupPremium({ + settingValues: { + "enable-embedding-sdk": true, + "embedding-app-origins-sdk": "metabase-sdk.com", + }, + }); + }); - expect( - await screen.findByRole("button", { name: "Configure" }), - ).toBeDisabled(); + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); - act(() => { - history.push(interactiveEmbeddingSettingsUrl); - }); + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeEnabled(); + }); - expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toHaveValue("metabase-sdk.com"); }); + }); + }); - it("should link to quickstart for interactive embedding", async () => { + describe("when interactive embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupPremium({ settingValues: { - "enable-embedding": false, - version: { tag: "v1.49.3" }, + "enable-embedding-interactive": true, + "embedding-app-origins-interactive": "localhost:9999", + "session-cookie-samesite": "strict", }, - }); - expect(getQuickStartLink()).toBeInTheDocument(); - expect(getQuickStartLink()).toHaveProperty( + }) + ).history; + }); + + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( "href", - "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=pro-self-hosted&utm_media=embed-settings", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings", + ); + }); + + it("should allow access to interactive embedding settings page", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + expect( + withinInteractiveEmbeddingCard.queryByRole("link", { + name: "Learn More", + }), + ).not.toBeInTheDocument(); + + await userEvent.click( + withinInteractiveEmbeddingCard.getByRole("button", { + name: "Configure", + }), + ); + + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).toBeChecked(); + await userEvent.click( + screen.getByLabelText("Enable Interactive embedding"), + ); + + expect(screen.getByLabelText("Authorized origins")).toBeEnabled(); + expect(screen.getByLabelText("Authorized origins")).toHaveValue( + "localhost:9999", + ); + + expect(screen.getByText("SameSite cookie setting")).toBeInTheDocument(); + expect( + screen.getByText("Strict (not recommended)"), + ).toBeInTheDocument(); + + expect(history.getCurrentLocation().pathname).toEqual( + interactiveEmbeddingSettingsUrl, ); }); }); }); - describe("when the embedding is enabled", () => { - it("should allow going to static embedding settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": true }, + + it("should link to quickstart for interactive embedding", async () => { + await setupPremium({ + settingValues: { + "enable-embedding": false, + version: { tag: "v1.49.3" }, + }, + }); + expect(getInteractiveEmbeddingQuickStartLink()).toBeInTheDocument(); + expect(getInteractiveEmbeddingQuickStartLink()).toHaveProperty( + "href", + "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=pro-self-hosted&utm_media=embed-settings", + ); + }); + + it("should not redirect users back to embedding settings page when visiting the full-app embedding page when embedding is not enabled", async () => { + await setup({ + settings: [createMockSettingDefinition({ key: "enable-embedding" })], + settingValues: createMockSettings({ "enable-embedding": false }), + tokenFeatures: createMockTokenFeatures({ + embedding: false, + embedding_sdk: false, + }), + hasEnterprisePlugins: true, + initialRoute: FULL_APP_EMBEDDING_URL, + }); + + expect(screen.queryByText("Static embedding")).not.toBeInTheDocument(); + expect( + screen.queryByText("Embedded analytics SDK"), + ).not.toBeInTheDocument(); + }); + + describe("self-hosted (pro)", () => { + beforeEach(async () => { + await setupPremium({ + isHosted: false, + settingValues: { "is-hosted?": false }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), + ); + }); + + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-self-hosted", + ); + }); + + it("should show quick start section", () => { + expect(screen.getByText("Get started")).toBeInTheDocument(); + expect( + screen.queryByText("Use the SDK with API keys for development."), + ).not.toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-self-hosted", + ); + }); + + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "Enter the origins for the websites or apps where you want to allow SDK embedding, separated by a space. Localhost is automatically included.", + ), + ).toBeInTheDocument(); }); - await goToStaticEmbeddingSettings(); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); + + expect( + within(documentationText).getByRole("link", { + name: "documentation", + }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-self-hosted", + ); + }); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + it("should not show version pinning section", () => { + expect(screen.queryByText("Version pinning")).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Request version pinning" }), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("cloud (Pro)", () => { + beforeEach(async () => { + await setupPremium({ + isHosted: true, + settingValues: { "is-hosted?": true }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), + ); }); - it("should allow going to interactive embedding settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": true }, + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-cloud", + ); + }); + + it("should show quick start section", () => { + expect(screen.getByText("Get started")).toBeInTheDocument(); + expect( + screen.queryByText("Use the SDK with API keys for development."), + ).not.toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-cloud", + ); + }); + + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "Enter the origins for the websites or apps where you want to allow SDK embedding, separated by a space. Localhost is automatically included.", + ), + ).toBeInTheDocument(); }); - await goToInteractiveEmbeddingSettings(); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); + + expect( + within(documentationText).getByRole("link", { + name: "documentation", + }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-cloud", + ); + }); + + it("should show version pinning section", () => { + expect(screen.getByText("Version pinning")).toBeInTheDocument(); + expect( + screen.getByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Request version pinning" }), + ).toHaveProperty("href", "mailto:help@metabase.com"); + }); + }); + }); + + describe("when environment variables are set", () => { + it("should show `Set by environment variable` when the embedding-app-origins-interactive is an env var", async () => { + await setupPremium({ + settingValues: { + "embedding-app-origins-interactive": null, + "enable-embedding-interactive": true, + "enable-embedding": true, + }, + isEnvVar: true, + }); + + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + await userEvent.click( + withinInteractiveEmbeddingCard.getByText("Configure"), + ); + + const withinEnvVarMessage = within( + screen.getByTestId("setting-env-var-message"), + ); + + expect( + withinEnvVarMessage.getByText(/this has been set by the/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/embedding-app-origins-interactive/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/environment variable/i), + ).toBeInTheDocument(); + }); + + it("should show `Set by environment variable` when the embedding-app-origins-sdk is an env var", async () => { + await setupPremium({ + settingValues: { "embedding-app-origins-sdk": null }, + isEnvVar: true, + }); + + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + await userEvent.click(withinEmbeddingSdkCard.getByText("Configure")); + + const withinEnvVarMessage = within( + screen.getByTestId("setting-env-var-message"), + ); + + expect( + withinEnvVarMessage.getByText(/this has been set by the/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/embedding-app-origins-sdk/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/environment variable/i), + ).toBeInTheDocument(); + }); + + it("should show `Set by environment variable` when the enable-embedding-static is an env var", async () => { + await setupPremium({ + settingValues: { "enable-embedding-static": true }, + isEnvVar: true, + }); + + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-sdk is an env var", async () => { + await setupPremium({ + settingValues: { "enable-embedding-sdk": true }, + isEnvVar: true, + }); + + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + expect( + withinEmbeddingSdkCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-interactive is an env var", async () => { + await setupPremium({ + settingValues: { "enable-embedding-interactive": true }, + isEnvVar: true, + }); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(interactiveEmbeddingSettingsUrl); + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + expect( + withinInteractiveEmbeddingCard.getByText( + "Set via environment variable", + ), + ).toBeVisible(); }); }); }); diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx index 46e942e955074..bf251473a794c 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx @@ -1,10 +1,12 @@ import userEvent from "@testing-library/user-event"; import fetchMock from "fetch-mock"; +import _ from "underscore"; -import { screen } from "__support__/ui"; +import { screen, within } from "__support__/ui"; import { checkNotNull } from "metabase/lib/types"; import type { Settings } from "metabase-types/api"; import { + createMockSettingDefinition, createMockSettings, createMockTokenFeatures, } from "metabase-types/api/mocks"; @@ -13,19 +15,35 @@ import { setup } from "../setup"; export type SetupOpts = { settingValues?: Partial; + isEnvVar?: boolean; + isHosted?: boolean; hasEmbeddingFeature?: boolean; hasEnterprisePlugins?: boolean; }; export const setupEmbedding = async ({ - settingValues, + settingValues = {}, + isEnvVar = false, + isHosted = false, hasEmbeddingFeature = false, hasEnterprisePlugins = false, }: SetupOpts) => { const returnedValue = await setup({ + settings: _.pairs>(settingValues).map(([key, value]) => + createMockSettingDefinition({ + key, + value, + is_env_setting: isEnvVar, + // in reality this would be the MB_[whatever] env name, but + // we can just use the key for easier testing + env_name: key, + }), + ), settingValues: createMockSettings(settingValues), tokenFeatures: createMockTokenFeatures({ + hosting: isHosted, embedding: hasEmbeddingFeature, + embedding_sdk: hasEmbeddingFeature, }), hasEnterprisePlugins, }); @@ -38,16 +56,12 @@ export const setupEmbedding = async ({ return { ...returnedValue, history: checkNotNull(returnedValue.history) }; }; -export const goToStaticEmbeddingSettings = async () => { - await userEvent.click(screen.getByText("Manage")); -}; - -export const goToInteractiveEmbeddingSettings = async () => { - await userEvent.click(screen.getByText("Configure")); -}; - -export const getQuickStartLink = () => { - return screen.getByRole("link", { name: "Check out our Quick Start" }); +export const getInteractiveEmbeddingQuickStartLink = () => { + return within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ).getByRole("link", { name: "Check out our Quick Start" }); }; export const embeddingSettingsUrl = diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/types.ts b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/types.ts new file mode 100644 index 0000000000000..ecadace66d74a --- /dev/null +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/types.ts @@ -0,0 +1,3 @@ +import type { setupEmbedding } from "./setup"; + +export type History = Awaited>["history"]; diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx index 203e64468a6fb..fa7cc58ada974 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import fetchMock from "fetch-mock"; import { setupGroupsEndpoint } from "__support__/server-mocks"; -import { screen } from "__support__/ui"; +import { screen, within } from "__support__/ui"; import { createMockGroup, createMockSettingDefinition, @@ -48,7 +48,14 @@ describe("SettingsEditor", () => { }); await userEvent.click(screen.getByText("Embedding")); - await userEvent.click(screen.getByText("Interactive embedding")); + expect( + within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ).getByRole("link", { name: "Learn More" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Authorized origins")).not.toBeInTheDocument(); expect( screen.queryByText("SameSite cookie setting"), diff --git a/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx b/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx index 3f7386be4e785..9e9a822aeff9a 100644 --- a/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx @@ -1,4 +1,5 @@ import userEvent from "@testing-library/user-event"; +import _ from "underscore"; import { renderWithProviders, screen } from "__support__/ui"; import { createMockSettingDefinition } from "metabase-types/api/mocks"; @@ -79,11 +80,15 @@ describe("AuthCard", () => { }); }); -const getSetting = (opts?: Partial): AuthSetting => - createMockSettingDefinition({ +const getSetting = (opts?: Partial): AuthSetting => { + const settingDefinition = createMockSettingDefinition({ + key: "google-auth-enabled", value: false, ...opts, - }) as AuthSetting; + }); + + return _.omit(settingDefinition, "key") as AuthSetting; +}; const getProps = (opts?: Partial): AuthCardProps => ({ setting: getSetting(), diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSdkSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSdkSettings.tsx new file mode 100644 index 0000000000000..605fda6611798 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSdkSettings.tsx @@ -0,0 +1,242 @@ +import { match } from "ts-pattern"; +import { jt, t } from "ttag"; + +import { useDocsUrl, useMergeSetting, useSetting } from "metabase/common/hooks"; +import { getPlan } from "metabase/common/utils/plan"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import ExternalLink from "metabase/core/components/ExternalLink"; +import { useSelector } from "metabase/lib/redux"; +import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins"; +import { + getLearnUrl, + getSetting, + getUpgradeUrl, +} from "metabase/selectors/settings"; +import { Alert, Box, Button, Icon, Stack, Text } from "metabase/ui"; + +import SettingHeader from "../SettingHeader"; +import { SetByEnvVarWrapper } from "../SettingsSetting"; +import { SwitchWithSetByEnvVar } from "../widgets/EmbeddingOption/SwitchWithSetByEnvVar"; +import { SettingTextInput } from "../widgets/SettingTextInput"; + +import type { AdminSettingComponentProps } from "./types"; + +export function EmbeddingSdkSettings({ + updateSetting, +}: AdminSettingComponentProps) { + const isEE = PLUGIN_EMBEDDING_SDK.isEnabled(); + const isEmbeddingSdkEnabled = useSetting("enable-embedding-sdk"); + const canEditSdkOrigins = isEE && isEmbeddingSdkEnabled; + + const plan = useSelector(state => + getPlan(getSetting(state, "token-features")), + ); + + const addUtmToLink = (url: string) => + `${url}?${new URLSearchParams({ + utm_source: "product", + utm_medium: "docs", + utm_campaign: "embedding-sdk", + utm_content: "embedding-sdk-admin", + source_plan: plan, + })}`; + + const isHosted = useSetting("is-hosted?"); + + const upgradeUrl = useSelector(state => + getUpgradeUrl(state, { + utm_campaign: "embedding-sdk", + utm_content: "embedding-sdk-admin", + }), + ); + + const sdkOriginsSetting = useMergeSetting( + !isEE + ? { + key: "embedding-app-origins-sdk", + placeholder: "https://*.example.com", + display_name: t`Cross-Origin Resource Sharing (CORS)`, + description: jt`Try out the SDK on localhost. To enable other sites, ${( + + {t`upgrade to Metabase Pro`} + + )} and Enter the origins for the websites or apps where you want to allow SDK embedding.`, + } + : { + key: "embedding-app-origins-sdk", + placeholder: "https://*.example.com", + display_name: t`Cross-Origin Resource Sharing (CORS)`, + description: t`Enter the origins for the websites or apps where you want to allow SDK embedding, separated by a space. Localhost is automatically included.`, + }, + ); + + function handleChangeSdkOrigins(value: string | null) { + updateSetting({ key: sdkOriginsSetting.key }, value); + } + + function handleToggleEmbeddingSdk(value: boolean) { + updateSetting({ key: "enable-embedding-sdk" }, value); + } + + const { url: activationUrl } = useDocsUrl( + "paid-features/activating-the-enterprise-edition", + ); + + const switchMetabaseBinariesUrl = addUtmToLink(activationUrl); + + const implementJwtUrl = addUtmToLink( + getLearnUrl("metabase-basics/embedding/securing-embeds"), + ); + + const quickStartUrl = addUtmToLink("https://metaba.se/sdk-quick-start"); + const documentationUrl = addUtmToLink("https://metaba.se/sdk-docs"); + + const apiKeyBannerText = match({ + isOSS: !isEE && !isHosted, + isCloudStarter: !isEE && isHosted, + isEE, + }) + .with( + { isOSS: true }, + () => + jt`You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, ${( + + switch Metabase binaries + + )}, ${( + + {t`upgrade to Metabase Pro`} + + )} and ${( + + {t`implement JWT SSO`} + + )}.`, + ) + .with( + { isCloudStarter: true }, + () => + jt`You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, ${( + + {t`upgrade to Metabase Pro`} + + )} and ${( + + {t`implement JWT SSO`} + + )}.`, + ) + .with( + { isEE: true }, + () => + jt`You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, ${( + + {t`implement JWT SSO`} + + )}.`, + ) + .otherwise(() => null); + + return ( + + + + + + + } + bg="var(--mb-color-background-info)" + style={{ + borderColor: "var(--mb-color-border)", + }} + variant="outline" + px="lg" + py="md" + maw={620} + > + {apiKeyBannerText} + + + + + + + + + + + + + + + {isEE && isHosted && ( + + + + + )} + + + {jt`Check out the ${( + + {t`documentation`} + + )} for more.`} + + + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSettings.tsx new file mode 100644 index 0000000000000..a99134e8b03e3 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSettings.tsx @@ -0,0 +1,54 @@ +import { t } from "ttag"; + +import CS from "metabase/css/core/index.css"; +import { Box, Stack, Text } from "metabase/ui"; + +import SettingHeader from "../SettingHeader"; +import { + EmbeddingSdkOptionCard, + InteractiveEmbeddingOptionCard, + StaticEmbeddingOptionCard, +} from "../widgets/EmbeddingOption"; + +import type { AdminSettingComponentProps } from "./types"; + +export function EmbeddingSettings({ + updateSetting, +}: AdminSettingComponentProps) { + function handleToggleStaticEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-static" }, value); + } + + function handleToggleEmbeddingSdk(value: boolean) { + updateSetting({ key: "enable-embedding-sdk" }, value); + } + + function handleToggleInteractiveEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-interactive" }, value); + } + + return ( + + + + + + + {t`Embed dashboards, questions, or the entire Metabase app into your application. Integrate with your server code to create a secure environment, limited to specific users or organizations.`} + + + + + + + + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings.tsx new file mode 100644 index 0000000000000..0c6aa7dfb7bd0 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings.tsx @@ -0,0 +1,7 @@ +import RedirectWidget from "../widgets/RedirectWidget"; + +export function InteractiveEmbeddingSettings() { + return ( + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/StaticEmbeddingSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/StaticEmbeddingSettings.tsx new file mode 100644 index 0000000000000..f2ecae560e87d --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/StaticEmbeddingSettings.tsx @@ -0,0 +1,83 @@ +import { t } from "ttag"; + +import { useMergeSetting, useSetting } from "metabase/common/hooks"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import { Box, Stack } from "metabase/ui"; +import type { SettingValue } from "metabase-types/api"; + +import type { SettingElement } from "../../types"; +import SettingHeader from "../SettingHeader"; +import { SettingTitle } from "../SettingHeader/SettingHeader.styled"; +import { SetByEnvVarWrapper } from "../SettingsSetting"; +import { SwitchWithSetByEnvVar } from "../widgets/EmbeddingOption/SwitchWithSetByEnvVar"; +import { EmbeddedResources } from "../widgets/PublicLinksListing/EmbeddedResources"; +import SecretKeyWidget from "../widgets/SecretKeyWidget"; + +import type { AdminSettingComponentProps } from "./types"; + +const EMBEDDING_SECRET_KEY_SETTING: SettingElement<"embedding-secret-key"> = { + key: "embedding-secret-key", + display_name: t`Embedding secret key`, + description: t`Standalone Embed Secret Key used to sign JSON Web Tokens for requests to /api/embed endpoints. This lets you create a secure environment limited to specific users or organizations.`, +} as const; + +export function StaticEmbeddingSettings({ + updateSetting, +}: AdminSettingComponentProps) { + const embeddingSecretKeySetting = useMergeSetting( + EMBEDDING_SECRET_KEY_SETTING, + ); + + const isStaticEmbeddingEnabled = useSetting("enable-embedding-static"); + + const handleChangeEmbeddingSecretKey = ( + value: SettingValue<"embedding-secret-key">, + ) => updateSetting({ key: embeddingSecretKeySetting.key }, value); + + function handleToggleStaticEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-static" }, value); + } + + return ( + + + + + + + + + + + + {t`Manage embeds`} + {/* Right now, when changing the setting, we don't have a mechanism to reload the data. + For now we'll have to use this key. */} + + + + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/index.ts b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/index.ts new file mode 100644 index 0000000000000..cca1b5c95e886 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/index.ts @@ -0,0 +1,4 @@ +export { EmbeddingSettings } from "./EmbeddingSettings"; +export { StaticEmbeddingSettings } from "./StaticEmbeddingSettings"; +export { EmbeddingSdkSettings } from "./EmbeddingSdkSettings"; +export { InteractiveEmbeddingSettings } from "./InteractiveEmbeddingSettings"; diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/types.ts b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/types.ts new file mode 100644 index 0000000000000..4984ca78ff2f4 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/types.ts @@ -0,0 +1,10 @@ +import type { SettingKey, Settings } from "metabase-types/api"; + +type Setting = { key: K }; + +export interface AdminSettingComponentProps { + updateSetting: ( + setting: Setting, + value: Settings[K] | null, + ) => Promise; +} diff --git a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx index 80c8bef9bcd46..dbc09ec4820dc 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx @@ -23,7 +23,7 @@ import SettingNumber from "./widgets/SettingNumber"; import SettingPassword from "./widgets/SettingPassword"; import SettingRadio from "./widgets/SettingRadio"; import SettingText from "./widgets/SettingText"; -import SettingToggle from "./widgets/SettingToggle"; +import { SettingToggle } from "./widgets/SettingToggle"; import SettingSelect from "./widgets/deprecated/SettingSelect"; const SETTING_WIDGET_MAP = { @@ -115,9 +115,11 @@ export const SetByEnvVar = ({ setting }) => { const { url: docsUrl } = useGetEnvVarDocsUrl(setting.env_name); return ( - + {jt`This has been set by the ${( - {setting.env_name} + + {setting.env_name} + )} environment variable.`} ); diff --git a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx index 7717cff05504a..b363923564ddd 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx +++ b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx @@ -23,7 +23,7 @@ const updateChannelSetting = { { name: c("describes a software version").t`Beta`, value: "beta" }, { name: c("describes a software version").t`Nightly`, value: "nightly" }, ], -}; +} as const; export function SettingsUpdatesForm({ elements, diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.styled.tsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.styled.tsx deleted file mode 100644 index 1e9f118b0a352..0000000000000 --- a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.styled.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import styled from "@emotion/styled"; - -import Card from "metabase/components/Card"; -import ExternalLink from "metabase/core/components/ExternalLink"; -import { space } from "metabase/styled-components/theme"; - -export const StyledCard = styled(Card)` - padding: 2.5rem; - width: 100%; - max-width: 40rem; -`; - -export const Label = styled.span` - padding: ${space(0)} ${space(1)}; - display: inline-block; - line-height: 1.3; - font-size: 0.75rem; - font-weight: 700; - border-radius: 0.25rem; - text-transform: uppercase; - color: var(--mb-color-text-white); - background: var(--mb-color-brand); -`; - -export const BoldExternalLink = styled(ExternalLink)` - color: var(--mb-color-brand); - font-weight: bold; -`; diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx index 571ad97e8c9fe..a681a79f2c5ba 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx @@ -1,45 +1,39 @@ -import { Link } from "react-router"; -import { jt, t } from "ttag"; +import type { ReactNode } from "react"; -import { useSetting } from "metabase/common/hooks"; -import { getPlan } from "metabase/common/utils/plan"; -import ExternalLink from "metabase/core/components/ExternalLink"; -import { useSelector } from "metabase/lib/redux"; -import { PLUGIN_EMBEDDING } from "metabase/plugins"; -import { - getDocsUrl, - getSetting, - getUpgradeUrl, -} from "metabase/selectors/settings"; -import type { ButtonProps } from "metabase/ui"; -import { Button, Flex, Text, Title } from "metabase/ui"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; +import { Flex, Paper, Text, Title } from "metabase/ui"; -import { BoldExternalLink, Label, StyledCard } from "./EmbeddingOption.styled"; -import InteractiveEmbeddingOff from "./InteractiveEmbeddingOff.svg?component"; -import InteractiveEmbeddingOn from "./InteractiveEmbeddingOn.svg?component"; -import StaticEmbeddingOff from "./StaticEmbeddingOff.svg?component"; -import StaticEmbeddingOn from "./StaticEmbeddingOn.svg?component"; -interface EmbeddingOptionProps { +type EmbeddingOptionProps = { title: string; - label?: string; - children?: React.ReactNode; - description: React.ReactNode; - icon: React.ReactNode; -} + label?: ReactNode; + children?: ReactNode; + description: ReactNode; + icon: ReactNode; +}; -function EmbeddingOption({ +export function EmbeddingOption({ title, label, description, children, icon, }: EmbeddingOptionProps) { + const titleId = useUniqueId(); return ( - + {icon} - - {title} - {label && } + + + {title} + + {label} {description} @@ -47,114 +41,6 @@ function EmbeddingOption({ {children} - + ); } - -export const StaticEmbeddingOptionCard = () => { - const enabled = useSetting("enable-embedding"); - const upgradeUrl = useSelector(state => - getUpgradeUrl(state, { utm_content: "embed-settings" }), - ); - const shouldPromptToUpgrade = !PLUGIN_EMBEDDING.isEnabled(); - - const upgradeText = jt`A "powered by Metabase" banner appears on static embeds. You can ${( - - {t`upgrade to a paid plan`} - - )} to remove it.`; - - return ( - : } - title={t`Static embedding`} - description={jt`Use static embedding when you don’t want to give people ad hoc query access to their data for whatever reason, or you want to present data that applies to all of your tenants at once.${ - shouldPromptToUpgrade && ( - - {upgradeText} - - ) - }`} - > - - {t`Manage`} - - - ); -}; - -export const InteractiveEmbeddingOptionCard = () => { - const isEE = PLUGIN_EMBEDDING.isEnabled(); - const plan = useSelector(state => - getPlan(getSetting(state, "token-features")), - ); - const enabled = useSetting("enable-embedding"); - const quickStartUrl = useSelector(state => - getDocsUrl(state, { - page: "embedding/interactive-embedding-quick-start-guide", - }), - ); - - return ( - - ) : ( - - ) - } - title={t`Interactive embedding`} - label={t`PRO & ENTERPRISE`} - description={jt`Use interactive embedding when you want to ${( - - {t`offer multi-tenant, self-service analytics`} - - )} and people want to create their own questions, dashboards, models, and more, all in their own data sandbox.`} - > - - {t`Check out our Quick Start`} - - {isEE ? ( - - {t`Configure`} - - ) : ( - - )} - - ); -}; - -// component={Link} breaks the styling when the button is disabled -// disabling a link button doesn't look like a common enough scenario to make an exported component -const LinkButton = ({ - to, - disabled, - ...buttonProps -}: { to: string; disabled?: boolean } & ButtonProps) => { - return disabled ? ( - + )} + + + + ); +}; diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/index.ts b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/index.ts new file mode 100644 index 0000000000000..907cd9265623c --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/index.ts @@ -0,0 +1 @@ +export * from "./InteractiveEmbeddingOptionCard"; diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/LinkButton.tsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/LinkButton.tsx new file mode 100644 index 0000000000000..581365153515a --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/LinkButton.tsx @@ -0,0 +1,19 @@ +import { Link } from "react-router"; + +import { Button, type ButtonProps } from "metabase/ui"; + +// component={Link} breaks the styling when the button is disabled +// disabling a link button doesn't look like a common enough scenario to make an exported component +export const LinkButton = ({ + to, + disabled, + ...buttonProps +}: { to: string; disabled?: boolean } & ButtonProps) => { + return disabled ? ( + + + + + {loading ? ( +
+ +
+ ) : ( + { + setIsOpen(false); + deletePublicLink(e); + }} + removeButtonLabel={t`Remove public link`} + removeTooltipLabel={t`Affects both public link and embed URL for this dashboard`} + onCopy={() => + trackPublicEmbedCodeCopied({ + artifact: resourceType, + source: "public-embed", + }) + } + /> + )} +
+
+ + + ); +}; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx index 86c853187d96d..9657570a81aa4 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx @@ -1,27 +1,28 @@ -import type { MouseEvent } from "react"; +import cx from "classnames"; import { useState } from "react"; import { t } from "ttag"; +import { getPlan } from "metabase/common/utils/plan"; import Link from "metabase/core/components/Link"; +import CS from "metabase/css/core/index.css"; +import { Badge } from "metabase/home/components/EmbedHomepage/Badge"; import { useSelector } from "metabase/lib/redux"; -import { - trackPublicEmbedCodeCopied, - trackPublicLinkRemoved, -} from "metabase/public/lib/analytics"; +import { PLUGIN_EMBEDDING } from "metabase/plugins"; +import { trackPublicLinkRemoved } from "metabase/public/lib/analytics"; import { getPublicEmbedHTML } from "metabase/public/lib/code"; import type { EmbedResource, EmbedResourceType, } from "metabase/public/lib/types"; import { getSetting } from "metabase/selectors/settings"; -import { PublicLinkCopyPanel } from "metabase/sharing/components/PublicLinkPopover/PublicLinkCopyPanel"; import type { ExportFormatType } from "metabase/sharing/components/PublicLinkPopover/types"; -import { Anchor, Group, Stack, Text } from "metabase/ui"; +import { Group, Icon, List, Stack, Text } from "metabase/ui"; -import { InteractiveEmbeddingCTA } from "./InteractiveEmbeddingCTA"; +import { PublicEmbedCard } from "./PublicEmbedCard"; import { SharingPaneButton } from "./SharingPaneButton/SharingPaneButton"; -import { SharingPaneActionButton } from "./SharingPaneButton/SharingPaneButton.styled"; -import { PublicEmbedIcon, StaticEmbedIcon } from "./icons"; +import SdkIllustration from "./illustrations/embedding-sdk.svg?component"; +import InteractiveEmbeddingIllustration from "./illustrations/interactive-embedding.svg?component"; +import StaticEmbeddingIllustration from "./illustrations/static-embedding.svg?component"; interface SelectEmbedTypePaneProps { resource: EmbedResource; @@ -42,14 +43,25 @@ export function SelectEmbedTypePane({ }: SelectEmbedTypePaneProps) { const hasPublicLink = resource.public_uuid != null; + const interactiveEmbeddingCta = useInteractiveEmbeddingCta(); + + const plan = useSelector(state => + getPlan(getSetting(state, "token-features")), + ); + + const utmTags = `?utm_source=product&source_plan=${plan}&utm_content=embed-modal`; + const isPublicSharingEnabled = useSelector(state => getSetting(state, "enable-public-sharing"), ); const [isLoadingLink, setIsLoadingLink] = useState(false); - const createPublicLink = async (e: MouseEvent) => { - e.stopPropagation(); + const publicEmbedCode = + resource.public_uuid && + getPublicEmbedHTML(getPublicUrl(resource.public_uuid)); + + const createPublicLink = async () => { if (!isLoadingLink && !hasPublicLink) { setIsLoadingLink(true); await onCreatePublicLink(); @@ -57,8 +69,7 @@ export function SelectEmbedTypePane({ } }; - const deletePublicLink = async (e: MouseEvent) => { - e.stopPropagation(); + const deletePublicLink = async () => { if (!isLoadingLink && hasPublicLink) { setIsLoadingLink(true); @@ -72,93 +83,134 @@ export function SelectEmbedTypePane({ } }; - const publicLinkInfoText = - !isLoadingLink && hasPublicLink - ? // TextInput has a hardcoded marginTop that we need to account for here. - t`Just copy this snippet to add a publicly-visible iframe embed to your web page or blog post.` - : t`Use this to add a publicly-visible iframe embed to your web page or blog post.`; - - const getPublicLinkElement = () => { - if (isLoadingLink) { - return ( - {t`Loading…`} - ); - } - - if (hasPublicLink && resource.public_uuid != null) { - const iframeSource = getPublicEmbedHTML( - getPublicUrl(resource.public_uuid), - ); - - return ( - - trackPublicEmbedCodeCopied({ - artifact: resourceType, - source: "public-embed", - }) - } - onRemoveLink={deletePublicLink} - removeButtonLabel={t`Remove public URL`} - removeTooltipLabel={t`Affects both embed URL and public link for this dashboard`} - /> - ); - } - - return ( - {t`Get an embed link`} - ); - }; - return ( - - + + + {/* STATIC EMBEDDING*/} } + title={t`Static embedding`} + illustration={} onClick={goToNextStep} > - - {resource.enable_embedding ? t`Edit settings` : t`Set this up`} - + + {t`Embedded, signed charts in iframes.`} + {t`No query builder or row-level data access.`} + {t`Data restriction with locked parameters.`} + - - {t`Public embeds and links are disabled.`}{" "} - {t`Settings`} - - ) + {/* INTERACTIVE EMBEDDING */} + + {t`Pro`}} + illustration={} + > + + {/* eslint-disable-next-line no-literal-metabase-strings -- only admin sees this */} + {t`Embed all of Metabase in an iframe.`} + {t`Let people can click on to explore.`} + {t`Customize appearance with your logo, font, and colors.`} + + + + + {/* REACT SDK */} + + + {t`Beta`} + {t`Pro`} + + } + illustration={} + externalLink + > + + {/* eslint-disable-next-line no-literal-metabase-strings -- visible only to admin */} + {t`Embed Metabase components with React (like standalone charts, dashboards, the Query Builder, and more)`} + {t`Manage access and interactivity per component`} + {t`Advanced customization options for styling`} + + + + + + {/* PUBLIC EMBEDDING */} + {isPublicSharingEnabled ? ( + + ) : ( + + {t`Public embeds and links are disabled.`}{" "} + {t`Settings`} + + )} + } > - {getPublicLinkElement()} - + {t`Compare options`} + - ); } + +export const useInteractiveEmbeddingCta = () => { + const isInteractiveEmbeddingEnabled = useSelector( + PLUGIN_EMBEDDING.isInteractiveEmbeddingEnabled, + ); + const plan = useSelector(state => + getPlan(getSetting(state, "token-features")), + ); + + if (isInteractiveEmbeddingEnabled) { + return { + url: "/admin/settings/embedding-in-other-applications/full-app", + }; + } + + return { + url: `https://www.metabase.com/product/embedded-analytics?${new URLSearchParams( + { + utm_source: "product", + utm_medium: "upsell", + utm_campaign: "embedding-interactive", + utm_content: "static-embed-popover", + source_plan: plan, + }, + )}`, + target: "_blank", + }; +}; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx index ec626ae9f1146..927da58d57c5d 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx @@ -70,40 +70,20 @@ const setup = ({ describe("SelectEmbedTypePane", () => { describe("static embed button", () => { describe("when the resource is published", () => { - it("should render `Edit settings`", () => { - setup({ isResourcePublished: true }); - - expect( - screen.getByRole("button", { name: "Edit settings" }), - ).toBeInTheDocument(); - }); - - it("should call `goToNextStep` with `application` when `Edit settings` is clicked", async () => { + it("should call `goToNextStep` with `application` when the static embedding option is clicked", async () => { const { goToNextStep } = setup({ isResourcePublished: true }); - await userEvent.click( - screen.getByRole("button", { name: "Edit settings" }), - ); + await userEvent.click(screen.getByText("Static embedding")); expect(goToNextStep).toHaveBeenCalled(); }); }); describe("when the resource is not published", () => { - it("should render `Set this up`", () => { - setup({ isResourcePublished: false }); - - expect( - screen.getByRole("button", { name: "Set this up" }), - ).toBeInTheDocument(); - }); - - it("should call `goToNextStep` with `application` when `Set this up` is clicked", async () => { - const { goToNextStep } = setup({ isResourcePublished: false }); + it("should call `goToNextStep` with `application` when the static embedding option is clicked", async () => { + const { goToNextStep } = setup({ isResourcePublished: true }); - await userEvent.click( - screen.getByRole("button", { name: "Set this up" }), - ); + await userEvent.click(screen.getByText("Static embedding")); expect(goToNextStep).toHaveBeenCalled(); }); @@ -112,29 +92,12 @@ describe("SelectEmbedTypePane", () => { describe("public embed button", () => { describe("when public sharing is disabled", () => { - it("should render link to settings and a disabled button with `Get an embed link`", () => { - setup({ isPublicSharingEnabled: false }); - - expect( - screen.getByText("Public embeds and links are disabled."), - ).toBeInTheDocument(); - expect( - screen.getByTestId("sharing-pane-settings-link"), - ).toBeInTheDocument(); - - const embedLinkButton = screen.getByRole("button", { - name: "Get an embed link", - }); - expect(embedLinkButton).toBeInTheDocument(); - expect(embedLinkButton).toBeDisabled(); - }); - it("should redirect to settings when public sharing is disabled and `Settings` is clicked", async () => { const { history } = setup({ isPublicSharingEnabled: false, }); - await userEvent.click(screen.getByTestId("sharing-pane-settings-link")); + await userEvent.click(screen.getByRole("link", { name: "Settings" })); expect(history.getCurrentLocation().pathname).toEqual( "/admin/settings/public-sharing", @@ -146,57 +109,49 @@ describe("SelectEmbedTypePane", () => { it("should render iframe link, copy button, `Copy snippet` description, and `Affects public url and link` tooltip", async () => { setup({ hasPublicLink: true, isPublicSharingEnabled: true }); - expect( - screen.getByText( - "Just copy this snippet to add a publicly-visible iframe embed to your web page or blog post.", - ), - ).toBeInTheDocument(); + await userEvent.click(screen.getByText("Get embedding code")); expect(screen.getByTestId("public-link-input")).toHaveDisplayValue( //s, ); expect(screen.getByTestId("copy-button")).toBeInTheDocument(); - expect(screen.getByText("Remove public URL")).toBeInTheDocument(); + expect(screen.getByText("Remove public link")).toBeInTheDocument(); - await userEvent.hover(screen.getByText("Remove public URL")); + await userEvent.hover(screen.getByText("Remove public link")); expect( screen.getByText( - "Affects both embed URL and public link for this dashboard", + "Affects both public link and embed URL for this dashboard", ), ).toBeInTheDocument(); }); - it("should call `onDeletePublicLink` when `Remove public URL` is clicked", async () => { + it("should call `onDeletePublicLink` when `Remove public link` is clicked", async () => { const { onDeletePublicLink } = setup({ hasPublicLink: true, isPublicSharingEnabled: true, }); - await userEvent.click(screen.getByText("Remove public URL")); + await userEvent.click(screen.getByText("Get embedding code")); + + await userEvent.click(screen.getByText("Remove public link")); expect(onDeletePublicLink).toHaveBeenCalled(); }); }); describe("when a public link doesn't exist", () => { - it("should render `Get an embed link` and `Use this` description", () => { + it("should render `Get embedding code`", () => { setup({ hasPublicLink: false, isPublicSharingEnabled: true }); - expect( - screen.getByText( - "Use this to add a publicly-visible iframe embed to your web page or blog post.", - ), - ).toBeInTheDocument(); - - expect(screen.getByText("Get an embed link")).toBeInTheDocument(); + expect(screen.getByText("Get embedding code")).toBeInTheDocument(); }); - it("should call `onCreatePublicLink` when `Get an embed link` is clicked", async () => { + it("should call `onCreatePublicLink` when `Get embedding code` is clicked", async () => { const { onCreatePublicLink } = setup({ isPublicSharingEnabled: true }); await userEvent.click( - screen.getByRole("button", { name: "Get an embed link" }), + screen.getByRole("button", { name: "Get embedding code" }), ); expect(onCreatePublicLink).toHaveBeenCalled(); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.module.css b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.module.css new file mode 100644 index 0000000000000..bcc720b175117 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.module.css @@ -0,0 +1,13 @@ +.Container:not([data-css-specificity-hack="🤷"]) { + cursor: pointer; + border-color: var(--mb-color-brand-light); + + --external-link-icon-color: var(--mb-color-text-light); + + &:hover { + border-color: var(--mb-base-color-blue-40); + background-color: var(--mb-color-bg-light); + + --external-link-icon-color: var(--mb-base-color-blue-40); + } +} diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx index 119bbe6c87319..c1202a9afae46 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx @@ -1,49 +1,51 @@ -import type { MouseEvent, MouseEventHandler, ReactNode } from "react"; +import type { MouseEventHandler, ReactNode } from "react"; -import { Box, Center, Stack, Text } from "metabase/ui"; +import { Box, Center, Group, Icon, Paper, Stack, Title } from "metabase/ui"; -import { - SharingPaneButtonContent, - SharingPaneButtonTitle, -} from "./SharingPaneButton.styled"; +import S from "./SharingPaneButton.module.css"; type SharingOptionProps = { illustration: JSX.Element; children: ReactNode; - header: string; - description: ReactNode | string; - disabled?: boolean; + title: string; + badge?: ReactNode; onClick?: MouseEventHandler; "data-testid"?: string; + externalLink?: boolean; }; export const SharingPaneButton = ({ illustration, children, - header, - description, - disabled, + title, onClick, + badge, + externalLink = false, "data-testid": dataTestId, }: SharingOptionProps) => ( - -
!disabled && onClick?.(event)} - > - - {illustration} - - {header} - - {description} - {children} - -
-
+ + {externalLink && ( + + + + )} +
{illustration}
+ + {title} + {badge} + + {children} +
+ ); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.styled.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.styled.tsx deleted file mode 100644 index b0c7845ae3620..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.styled.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { css } from "@emotion/react"; -import styled from "@emotion/styled"; - -import { SharingPaneButtonContent } from "../../SharingPaneButton/SharingPaneButton.styled"; - -interface PublicEmbedIconRootProps { - disabled: boolean; -} - -export const PublicEmbedIconRoot = styled.svg` - ${({ disabled }) => css` - color: var(--mb-color-bg-medium); - - .innerFill { - stroke: ${disabled - ? "var(--mb-color-text-light)" - : "var(--mb-color-bg-dark)"}; - opacity: ${disabled ? 0.5 : 1}; - } - `} - - ${({ disabled }) => - !disabled && - css` - ${SharingPaneButtonContent}:hover & { - color: var(--mb-color-bg-dark); - - .innerFill { - stroke: var(--mb-color-brand); - } - } - `} -`; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.tsx deleted file mode 100644 index c280caf384cba..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { PublicEmbedIconRoot } from "./PublicEmbedIcon.styled"; - -interface PublicEmbedIconProps { - disabled: boolean; -} -export const PublicEmbedIcon = ({ disabled }: PublicEmbedIconProps) => ( - - - - - - - -); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.styled.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.styled.tsx deleted file mode 100644 index 141747dc6163c..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.styled.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import styled from "@emotion/styled"; - -import { SharingPaneButtonContent } from "../../SharingPaneButton/SharingPaneButton.styled"; - -export const StaticEmbedIconRoot = styled.svg` - color: var(--mb-color-bg-medium); - - .innerFill { - fill: var(--mb-color-bg-dark); - fill-opacity: 0.5; - } - - ${SharingPaneButtonContent}:hover & { - color: var(--mb-color-focus); - - .innerFill { - fill: var(--mb-color-brand); - fill-opacity: 1; - } - } -`; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.tsx deleted file mode 100644 index ddb9fe0bc4b3b..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { StaticEmbedIconRoot } from "./StaticEmbedIcon.styled"; - -export const StaticEmbedIcon = () => ( - - - - - - - - -); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/index.ts b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/index.ts deleted file mode 100644 index 66d1fc6319e6e..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PublicEmbedIcon } from "./PublicEmbedIcon/PublicEmbedIcon"; -export { StaticEmbedIcon } from "./StaticEmbedIcon/StaticEmbedIcon"; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/embedding-sdk.svg b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/embedding-sdk.svg new file mode 100644 index 0000000000000..90603b02c8e07 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/embedding-sdk.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/interactive-embedding.svg b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/interactive-embedding.svg new file mode 100644 index 0000000000000..37e4fff8df891 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/interactive-embedding.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/static-embedding.svg b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/static-embedding.svg new file mode 100644 index 0000000000000..7768bde3f3036 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/static-embedding.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem.tsx b/frontend/src/metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem.tsx index 4b74c4bd75af6..357b24d2ca839 100644 --- a/frontend/src/metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem.tsx +++ b/frontend/src/metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem.tsx @@ -7,7 +7,7 @@ import { getUserIsAdmin } from "metabase/selectors/user"; import { Center, Icon, Menu, Stack, Text, Title } from "metabase/ui"; export function EmbedMenuItem({ onClick }: { onClick: () => void }) { - const isEmbeddingEnabled = useSetting("enable-embedding"); + const isStaticEmbeddingEnabled = useSetting("enable-embedding-static"); const isAdmin = useSelector(getUserIsAdmin); if (!isAdmin) { @@ -23,9 +23,9 @@ export function EmbedMenuItem({ onClick }: { onClick: () => void }) { } - onClick={isEmbeddingEnabled ? onClick : undefined} + onClick={isStaticEmbeddingEnabled ? onClick : undefined} > - {isEmbeddingEnabled ? ( + {isStaticEmbeddingEnabled ? ( {t`Embed`} ) : ( ({ minHeight: getSize({ size, sizes: SIZES }), background: theme.fn.themeColor("bg-white"), "&::placeholder": { - color: theme.fn.themeColor("text-light"), + color: "var(--mb-color-text-tertiary)", }, "&:disabled": { - backgroundColor: theme.fn.themeColor("bg-light"), + color: "var(--mb-color-text-disabled)", + backgroundColor: "var(--mb-color-background-disabled)", + "&::placeholder": { + color: "var(--mb-color-text-disabled)", + }, }, "&[data-invalid]": { color: theme.fn.themeColor("error"), diff --git a/resources/migrations/001_update_migrations.yaml b/resources/migrations/001_update_migrations.yaml index 46aa184601a88..ae7e1f6f0d0e6 100644 --- a/resources/migrations/001_update_migrations.yaml +++ b/resources/migrations/001_update_migrations.yaml @@ -9288,6 +9288,202 @@ databaseChangeLog: name: type indexName: idx_collection_type + - changeSet: + id: v51.2024-09-26T03:01:00 + author: escherize + comment: iff enable-embedding is set, copy -> enable-embedding-interactive + preConditions: + - onFail: MARK_RAN + - or: + - and: + - dbms: + type: postgresql + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE key = 'enable-embedding'; + - and: + - dbms: + type: mysql,mariadb + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE `key` = 'enable-embedding'; + - and: + - dbms: + type: h2 + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE "KEY" = 'enable-embedding'; + changes: + - sql: + dbms: postgresql + sql: >- + INSERT INTO setting (key, value) + SELECT 'enable-embedding-interactive', value + FROM setting + WHERE key = 'enable-embedding'; + - sql: + dbms: mysql,mariadb + sql: >- + INSERT INTO setting (`key`, `value`) + SELECT 'enable-embedding-interactive', value + FROM setting + WHERE `key` = 'enable-embedding'; + - sql: + dbms: h2 + sql: >- + INSERT INTO setting ("KEY", "VALUE") + SELECT 'enable-embedding-interactive', "VALUE" + FROM setting + WHERE "KEY" = 'enable-embedding'; + rollback: # not needed + + - changeSet: + id: v51.2024-09-26T03:02:00 + author: escherize + comment: iff enable-embedding is set, copy -> enable-embedding-static + preConditions: + - onFail: MARK_RAN + - or: + - and: + - dbms: + type: postgresql + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE key = 'enable-embedding'; + - and: + - dbms: + type: mysql,mariadb + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE `key` = 'enable-embedding'; + - and: + - dbms: + type: h2 + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE "KEY" = 'enable-embedding'; + changes: + - sql: + dbms: postgresql + sql: >- + INSERT INTO setting (key, value) + SELECT 'enable-embedding-static', value + FROM setting + WHERE key = 'enable-embedding'; + - sql: + dbms: mysql,mariadb + sql: >- + INSERT INTO setting (`key`, `value`) + SELECT 'enable-embedding-static', value + FROM setting + WHERE `key` = 'enable-embedding'; + - sql: + dbms: h2 + sql: >- + INSERT INTO setting ("KEY", "VALUE") + SELECT 'enable-embedding-static', "VALUE" + FROM setting + WHERE "KEY" = 'enable-embedding'; + rollback: # not needed + + - changeSet: + id: v51.2024-09-26T03:03:00 + author: escherize + comment: iff enable-embedding is set, copy -> enable-embedding-sdk + preConditions: + - onFail: MARK_RAN + - or: + - and: + - dbms: + type: postgresql + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE key = 'enable-embedding'; + - and: + - dbms: + type: mysql,mariadb + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE `key` = 'enable-embedding'; + - and: + - dbms: + type: h2 + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE "KEY" = 'enable-embedding'; + changes: + - sql: + dbms: postgresql + sql: >- + INSERT INTO setting (key, value) + SELECT 'enable-embedding-sdk', value + FROM setting + WHERE key = 'enable-embedding'; + - sql: + dbms: mysql,mariadb + sql: >- + INSERT INTO setting (`key`, `value`) + SELECT 'enable-embedding-sdk', value + FROM setting + WHERE `key` = 'enable-embedding'; + - sql: + dbms: h2 + sql: >- + INSERT INTO setting ("KEY", "VALUE") + SELECT 'enable-embedding-sdk', "VALUE" + FROM setting + WHERE "KEY" = 'enable-embedding'; + rollback: # not needed + + - changeSet: + id: v51.2024-09-26T03:04:00 + author: escherize + comment: iff embedding-app-origin is set, copy -> embedding-app-origins-interactive + preConditions: + - onFail: MARK_RAN + - or: + - and: + - dbms: + type: postgresql + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE key = 'embedding-app-origin'; + - and: + - dbms: + type: mysql,mariadb + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE `key` = 'embedding-app-origin'; + - and: + - dbms: + type: h2 + - sqlCheck: + expectedResult: 1 + sql: SELECT count(*) FROM setting WHERE "KEY" = 'embedding-app-origin'; + changes: + - sql: + dbms: postgresql + sql: >- + INSERT INTO setting (key, value) + SELECT 'embedding-app-origins-interactive', value + FROM setting + WHERE key = 'embedding-app-origin'; + - sql: + dbms: mysql,mariadb + sql: >- + INSERT INTO setting (`key`, `value`) + SELECT 'embedding-app-origins-interactive', value + FROM setting + WHERE `key` = 'embedding-app-origin'; + - sql: + dbms: h2 + sql: >- + INSERT INTO setting ("KEY", "VALUE") + SELECT 'embedding-app-origins-interactive', "VALUE" + FROM setting + WHERE "KEY" = 'embedding-app-origin'; + rollback: # not needed + # >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<< ######################################################################################################################## diff --git a/snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-0 b/snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-0 index 7edd13d2031ee..fc6cb238320e8 100644 --- a/snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-0 +++ b/snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-0 @@ -23,7 +23,7 @@ "type": [ "boolean", "null" - ], + ] }, "number_embedded_questions": { "description": "The number of embedded questions", diff --git a/snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-1 b/snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-1 new file mode 100644 index 0000000000000..61f77ebe2b075 --- /dev/null +++ b/snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-1 @@ -0,0 +1,57 @@ +{ + "$schema": "http://iglucentral.com/schemas/com.snowplowanalytics.self-desc/schema/jsonschema/1-0-0#", + "description": "Schema for tracking embedding enabled and disabled events", + "self": { + "vendor": "com.metabase", + "name": "embed_share", + "format": "jsonschema", + "version": "1-0-1" + }, + "type": "object", + "properties": { + "event": { + "description": "Event name", + "type": "string", + "enum": [ + "embedding_enabled", + "embedding_disabled", + "sdk_embedding_enabled", + "sdk_embedding_disabled", + "static_embedding_enabled", + "static_embedding_disabled", + "interactive_embedding_enabled", + "interactive_embedding_disabled" + ], + "maxLength": 1024 + }, + "authorized_origins_set": { + "description": "Boolean indicating whether authorized origins are set for embedding", + "type": [ + "boolean", + "null" + ] + }, + "number_embedded_questions": { + "description": "The number of embedded questions", + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 2147483647 + }, + "number_embedded_dashboards": { + "description": "The number of embedded dashboards", + "type": [ + "integer", + "null" + ], + "minimum": 0, + "maximum": 2147483647 + } + }, + "required": [ + "event" + ], + "additionalProperties": true +} diff --git a/src/metabase/analytics/stats.clj b/src/metabase/analytics/stats.clj index a4370bedc2ed1..874dcc671bfee 100644 --- a/src/metabase/analytics/stats.clj +++ b/src/metabase/analytics/stats.clj @@ -132,8 +132,14 @@ :sso_configured (google/google-auth-enabled) :instance_started (snowplow/instance-creation) :has_sample_data (t2/exists? Database, :is_sample true) - :enable_embedding (embed.settings/enable-embedding) - :embedding_app_origin_set (boolean (embed.settings/embedding-app-origin)) + :enable_embedding #_:clj-kondo/ignore (embed.settings/enable-embedding) + :enable_embedding_sdk (embed.settings/enable-embedding-sdk) + :enable_embedding_interactive (embed.settings/enable-embedding-interactive) + :embedding_app_origin_set (boolean (or + #_:clj-kondo/ignore (embed.settings/embedding-app-origin) + (embed.settings/embedding-app-origins-interactive) + (let [sdk-origins (embed.settings/embedding-app-origins-sdk)] + (and sdk-origins (not= "localhost:*" sdk-origins))))) :appearance_site_name (not= (public-settings/site-name) "Metabase") :appearance_help_link (public-settings/help-link) :appearance_logo (not= (public-settings/application-logo-url) "app/assets/img/logo.svg") @@ -646,13 +652,13 @@ {:name :interactive-embedding :available (premium-features/hide-embed-branding?) :enabled (and - (embed.settings/enable-embedding) - (boolean (embed.settings/embedding-app-origin)) + (embed.settings/enable-embedding-interactive) + (boolean (embed.settings/embedding-app-origins-interactive)) (public-settings/sso-enabled?))} {:name :static-embedding :available true :enabled (and - (embed.settings/enable-embedding) + (embed.settings/enable-embedding-static) (or (t2/exists? :model/Dashboard :enable_embedding true) (t2/exists? :model/Card :enable_embedding true)))} diff --git a/src/metabase/api/common/validation.clj b/src/metabase/api/common/validation.clj index ee33337f4e133..09ecdd0201f8e 100644 --- a/src/metabase/api/common/validation.clj +++ b/src/metabase/api/common/validation.clj @@ -20,7 +20,7 @@ (defn check-embedding-enabled "Is embedding of Cards or Objects (secured access via `/api/embed` endpoints with a signed JWT enabled?" [] - (api/check (embed.settings/enable-embedding) + (api/check (embed.settings/enable-embedding-static) [400 (tru "Embedding is not enabled.")])) (defn check-has-application-permission diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index b7a660958cca3..6ec9aadac08d1 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -175,7 +175,7 @@ :hosted? (premium-features/is-hosted?) :embedding {:interested? (not (= (embed.settings/embedding-homepage) :hidden)) :done? (= (embed.settings/embedding-homepage) :dismissed-done) - :app-origin (boolean (embed.settings/embedding-app-origin))} + :app-origin (boolean (embed.settings/embedding-app-origins-interactive))} :configured {:email (email/email-configured?) :slack (slack/slack-configured?) :sso (google/google-auth-enabled)} diff --git a/src/metabase/core.clj b/src/metabase/core.clj index 42c371e258505..f4e352e274bb2 100644 --- a/src/metabase/core.clj +++ b/src/metabase/core.clj @@ -2,6 +2,7 @@ (:require [clojure.string :as str] [clojure.tools.trace :as trace] + [environ.core :as env] [java-time.api :as t] [metabase.analytics.prometheus :as prometheus] [metabase.channel.core :as channel] @@ -12,6 +13,7 @@ [metabase.driver.h2] [metabase.driver.mysql] [metabase.driver.postgres] + [metabase.embed.settings :as embed.settings] [metabase.events :as events] [metabase.logger :as logger] [metabase.models.cloud-migration :as cloud-migration] @@ -156,6 +158,9 @@ (ensure-audit-db-installed!) (init-status/set-progress! 0.95) + (embed.settings/check-and-sync-settings-on-startup! env/env) + (init-status/set-progress! 0.97) + (settings/migrate-encrypted-settings!) ;; start scheduler at end of init! (task/start-scheduler!) diff --git a/src/metabase/embed/settings.clj b/src/metabase/embed/settings.clj index 7c228af0f26fd..b678c50a1e913 100644 --- a/src/metabase/embed/settings.clj +++ b/src/metabase/embed/settings.clj @@ -5,38 +5,214 @@ [crypto.random :as crypto-random] [metabase.analytics.snowplow :as snowplow] [metabase.models.setting :as setting :refer [defsetting]] - [metabase.public-settings :as public-settings] + [metabase.public-settings.premium-features :as premium-features] + [metabase.util :as u] [metabase.util.embed :as embed] [metabase.util.i18n :as i18n :refer [deferred-tru]] + [metabase.util.log :as log] + [metabase.util.malli :as mu] [toucan2.core :as t2])) -(defsetting embedding-app-origin - (deferred-tru "Allow this origin to embed the full {0} application" - (public-settings/application-name-for-setting-descriptions)) +(mu/defn- make-embedding-toggle-setter + "Creates a boolean setter for various boolean embedding-enabled flavors, all tracked by snowplow." + [setting-key :- :keyword event-name :- :string] + (fn [new-value] + (u/prog1 new-value + (let [old-value (setting/get-value-of-type :boolean setting-key)] + (when (not= new-value old-value) + (setting/set-value-of-type! :boolean setting-key new-value) + (when (and new-value (str/blank? (embed/embedding-secret-key))) + (embed/embedding-secret-key! (crypto-random/hex 32))) + (snowplow/track-event! ::snowplow/embed_share + {:event (keyword (str event-name (if new-value "-enabled" "-disabled"))) + :embedding-app-origin-set (boolean + (or (setting/get-value-of-type :string :embedding-app-origin) + (setting/get-value-of-type :string :embedding-app-origins-interactive) + (let [sdk-origins (setting/get-value-of-type :string :embedding-app-origins-sdk)] + (and sdk-origins (not= "localhost:*" sdk-origins))))) + :number-embedded-questions (t2/count :model/Card :enable_embedding true) + :number-embedded-dashboards (t2/count :model/Dashboard :enable_embedding true)})))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Embed Settings ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defsetting ^:deprecated enable-embedding + ;; To be removed in 0.53.0 + (deferred-tru "Allow admins to securely embed questions and dashboards within other applications?") + :type :boolean + :default false + :visibility :authenticated + :export? true + :audit :getter + :deprecated "0.51.0" + :setter (make-embedding-toggle-setter :enable-embedding "embedding")) + +(defsetting ^:deprecated embedding-app-origin + ;; To be removed in 0.53.0 + (deferred-tru "Allow this origin to embed the full Metabase application.") + ;; This value is usually gated by [[enable-embedding]] :feature :embedding + :deprecated "0.51.0" + :type :string + :export? false :visibility :public :audit :getter :encryption :no) -(defsetting enable-embedding - (deferred-tru "Allow admins to securely embed questions and dashboards within other applications?") +(defsetting enable-embedding-sdk + (deferred-tru "Allow admins to embed Metabase via the SDK?") :type :boolean :default false :visibility :authenticated - :export? true + :export? false + :audit :getter + :setter (make-embedding-toggle-setter :enable-embedding-sdk "sdk-embedding")) + +(mu/defn- ignore-localhost :- :string + "Remove localhost:* or localhost: from the list of origins." + [s :- [:maybe :string]] + (->> (str/split (or s "") #"\s+") + (remove #(re-matches #"localhost:(\*|\d+)" %)) + distinct + (str/join " ") + str/trim)) + +(mu/defn- add-localhost :- :string [s :- [:maybe :string]] + (->> s ignore-localhost (str "localhost:* ") str/trim)) + +(defn embedding-app-origins-sdk-setter + "The setter for [[embedding-app-origins-sdk]]. + + Checks that we have SDK embedding feature and that it's enabled, then sets the value accordingly." + [new-value] + (add-localhost ;; return the same value that is returned from the getter + (->> new-value + ignore-localhost + ;; Why ignore-localhost?, because localhost:* will always be allowed, so we don't need to store it, if we + ;; were to store it, and the value was set N times, it would have localhost:* prefixed N times. Also, we + ;; should not store localhost:port, since it's covered by localhost:* (which is the minumum value). + (setting/set-value-of-type! :string :embedding-app-origins-sdk)))) + +(defsetting embedding-app-origins-sdk + (deferred-tru "Allow Metabase SDK access to these space delimited origins.") + :type :string + :export? false + :visibility :public + :feature :embedding-sdk + :default "localhost:*" + :encryption :no :audit :getter - :setter (fn [new-value] - (when (not= new-value (setting/get-value-of-type :boolean :enable-embedding)) - (setting/set-value-of-type! :boolean :enable-embedding new-value) - (when (and new-value (str/blank? (embed/embedding-secret-key))) - (embed/embedding-secret-key! (crypto-random/hex 32))) - (snowplow/track-event! ::snowplow/embed_share - {:event (if new-value - :embedding-enabled - :embedding-disabled) - :embedding-app-origin-set (boolean (embedding-app-origin)) - :number-embedded-questions (t2/count :model/Card :enable_embedding true) - :number-embedded-dashboards (t2/count :model/Dashboard :enable_embedding true)})))) + :getter (fn embedding-app-origins-sdk-getter [] + (add-localhost (setting/get-value-of-type :string :embedding-app-origins-sdk))) + :setter embedding-app-origins-sdk-setter) + +(defsetting enable-embedding-interactive + (deferred-tru "Allow admins to embed Metabase via interactive embedding?") + :type :boolean + :default false + :visibility :authenticated + :export? false + :audit :getter + :setter (make-embedding-toggle-setter :enable-embedding-interactive "interactive-embedding")) + +(defsetting embedding-app-origins-interactive + (deferred-tru "Allow these space delimited origins to embed Metabase interactive.") + :type :string + :feature :embedding + :export? false + :visibility :public + :encryption :no + :audit :getter) + +(defsetting enable-embedding-static + (deferred-tru "Allow admins to embed Metabase via static embedding?") + :type :boolean + :default false + :visibility :authenticated + :export? false + :audit :getter + :setter (make-embedding-toggle-setter :enable-embedding-static "static-embedding")) + +(defn- check-enable-settings! + "Ensure either: nothing is set, the deprecated setting is set, or only supported settings are set" + [env] + (let [deprecated-enable-env-var-set? (some? (:mb-enable-embedding env)) + supported-enable-env-vars-set (select-keys env [:mb-enable-embedding-sdk :mb-enable-embedding-interactive :mb-enable-embedding-static])] + (when (and deprecated-enable-env-var-set? (seq supported-enable-env-vars-set)) + (throw (ex-info "Both deprecated and new enable-embedding env vars are set, please remove MB_ENABLE_EMBEDDING." + {:deprecated-enable-env-vars-set deprecated-enable-env-var-set? + :current-enable-env-vars-set supported-enable-env-vars-set}))))) + +(defn- sync-enable-settings! + "If Only the deprecated enable-embedding is set, we want to sync the new settings to the deprecated one." + [env] + ;; we use [[find]], so we get the value if it is ∈ #{true false}, and skips nil + (when-let [[_ enable-embedding-from-env] (find env :mb-enable-embedding)] + (log/warn (str/join "\n" + ["Setting MB_ENABLE_EMBEDDING is deprecated as of Metabase 0.51.0 and will be removed in a future version." + (str "Setting MB_ENABLE_EMBEDDING_SDK, MB_ENABLE_EMBEDDING_INTERACTIVE, " + "and MB_ENABLE_EMBEDDING_STATIC to match MB_ENABLE_EMBEDDING, which is " + (pr-str enable-embedding-from-env) ".")])) + (enable-embedding-sdk! enable-embedding-from-env) + (enable-embedding-interactive! enable-embedding-from-env) + (enable-embedding-static! enable-embedding-from-env))) + +(defn- check-origins-settings! + "Ensure either: nothing is set, the deprecated setting is set, or only supported settings are set" + [env] + (let [deprecated-origin-env-var-set? (some? (:mb-embedding-app-origin env)) + supported-origins-env-vars-set (select-keys env + [:mb-embedding-app-origins-sdk :mb-embedding-app-origins-interactive])] + (when (and deprecated-origin-env-var-set? (seq supported-origins-env-vars-set)) + (throw (ex-info "Both deprecated and new enable-embedding env vars are set, please remove MB_ENABLE_EMBEDDING." + {:deprecated-enable-env-vars-set deprecated-origin-env-var-set? + :current-enable-env-vars-set supported-origins-env-vars-set}))))) + +(defn- sync-origins-settings! + "If Only the deprecated enable-embedding is set, we want to sync the new settings to the deprecated one." + [env] + ;; we use [[find]], so we get the value if it is ∈ #{true false}, and skips nil + (when-let [[_ app-origin-from-env] (find env :mb-embedding-app-origin)] + (log/warn (str/join "\n" + ["Setting MB_EMBEDDING_APP_ORIGIN is deprecated as of Metabase 0.51.0 and will be removed in a future version." + (str "Setting MB_EMBEDDING_APP_ORIGINS_SDK, MB_EMBEDDING_APP_ORIGINS_INTERACTIVE " + " to match MB_ENABLE_EMBEDDING, which is " + (pr-str app-origin-from-env) ".")])) + (when (premium-features/has-feature? :embedding-sdk) + (embedding-app-origins-sdk! app-origin-from-env)) + (when (premium-features/has-feature? :embedding) + (embedding-app-origins-interactive! app-origin-from-env)))) + +(defn- check-settings! + "We want to disallow setting both deprecated embed settings, and the new ones at the same time. This is to prevent + confusion and to make sure that we're not setting the same thing twice." + [env] + (check-enable-settings! env) + (check-origins-settings! env)) + +(defn- sync-settings! + "Sync settings to ensure that we can accept `MB_ENABLE_EMBEDDING` and `MB_EMBEDDING_APP_ORIGIN`. This should always + be called after [[check-settings]] so we don't overwrite a setting!" + [env] + (sync-enable-settings! env) + (sync-origins-settings! env)) + +(defn check-and-sync-settings-on-startup! + "Check and sync settings on startup. This is to ensure that we don't have any conflicting settings. A conflicting + setting would be setting a deprecated setting and a new setting at the same time. If a deprecated setting is set + (and none of its corresponding new settings are set), we want to sync the deprecated setting to the new settings and + print a deprecation warning." + [env] + (check-settings! env) + (sync-settings! env)) + +(mu/defn some-embedding-enabled? :- :boolean + "Is any kind of embedding setup?" + [] + (or + #_:clj-kondo/ignore (enable-embedding) + (enable-embedding-static) + (enable-embedding-interactive) + (enable-embedding-sdk))) ;; settings for the embedding homepage (defsetting embedding-homepage diff --git a/src/metabase/server/middleware/security.clj b/src/metabase/server/middleware/security.clj index 78c8faae82488..6f244b168b522 100644 --- a/src/metabase/server/middleware/security.clj +++ b/src/metabase/server/middleware/security.clj @@ -10,6 +10,7 @@ [metabase.public-settings :as public-settings] [metabase.server.request.util :as req.util] [metabase.util.log :as log] + [metabase.util.malli :as mu] [ring.util.codec :refer [base64-encode]]) (:import (java.security MessageDigest SecureRandom))) @@ -45,9 +46,8 @@ "Expires" "Tue, 03 Jul 2001 06:00:00 GMT" "Last-Modified" (t/format :rfc-1123-date-time (t/zoned-date-time))}) -(defn- cache-far-future-headers +(def cache-far-future-headers "Headers that tell browsers to cache a static resource for a long time." - [] {"Cache-Control" "public, max-age=31536000"}) (def ^:private ^:const strict-transport-security-header @@ -114,21 +114,15 @@ :manifest-src ["'self'"]}] (format "%s %s; " (name k) (str/join " " vs))))}) -(defn- embedding-app-origin - [] - (when (and (embed.settings/enable-embedding) (embed.settings/embedding-app-origin)) - (embed.settings/embedding-app-origin))) - -(defn- embedding-app-origin-sdk - [] - (when (embed.settings/enable-embedding) - (str "localhost:* " (embed.settings/embedding-app-origin)))) - (defn- content-security-policy-header-with-frame-ancestors [allow-iframes? nonce] (update (content-security-policy-header nonce) "Content-Security-Policy" - #(format "%s frame-ancestors %s;" % (if allow-iframes? "*" (or (embedding-app-origin) "'none'"))))) + #(format "%s frame-ancestors %s;" % (if allow-iframes? "*" + (if-let [eao (and (embed.settings/enable-embedding-interactive) + (embed.settings/embedding-app-origins-interactive))] + eao + "'none'"))))) (defn parse-url "Returns an object with protocol, domain and port for the given url" @@ -171,9 +165,10 @@ (let [urls (str/split approved-origins-raw #" +")] (keep parse-url urls))) -(defn approved-origin? +(mu/defn approved-origin? "Returns true if `origin` should be allowed for CORS based on the `approved-origins`" - [raw-origin approved-origins-raw] + [raw-origin :- [:maybe :string] + approved-origins-raw :- [:maybe :string]] (boolean (when (and (seq raw-origin) (seq approved-origins-raw)) (let [approved-list (parse-approved-origins approved-origins-raw) @@ -187,39 +182,32 @@ (defn access-control-headers "Returns headers for CORS requests" - [origin] - (merge - (when - (approved-origin? origin (embedding-app-origin-sdk)) - {"Access-Control-Allow-Origin" origin - "Vary" "Origin"}) - - {"Access-Control-Allow-Headers" "*" - "Access-Control-Allow-Methods" "*" - "Access-Control-Expose-Headers" "X-Metabase-Anti-CSRF-Token"})) - -(defn- first-embedding-app-origin - "Return only the first embedding app origin." - [] - (some-> (embedding-app-origin) - (str/split #" ") - first)) + [origin enabled? approved-origins] + (when enabled? + (merge + (when (approved-origin? origin (if enabled? approved-origins "localhost:*")) + {"Access-Control-Allow-Origin" origin + "Vary" "Origin"}) + {"Access-Control-Allow-Headers" "*" + "Access-Control-Allow-Methods" "*" + "Access-Control-Expose-Headers" "X-Metabase-Anti-CSRF-Token"}))) (defn security-headers "Fetch a map of security headers that should be added to a response based on the passed options." [& {:keys [origin nonce allow-iframes? allow-cache?] :or {allow-iframes? false, allow-cache? false}}] (merge - (if allow-cache? - (cache-far-future-headers) - (cache-prevention-headers)) + (if allow-cache? cache-far-future-headers (cache-prevention-headers)) strict-transport-security-header (content-security-policy-header-with-frame-ancestors allow-iframes? nonce) - (when (embedding-app-origin-sdk) (access-control-headers origin)) + (access-control-headers origin + (embed.settings/enable-embedding-sdk) + (embed.settings/embedding-app-origins-sdk)) (when-not allow-iframes? ;; Tell browsers not to render our site as an iframe (prevent clickjacking) - {"X-Frame-Options" (if (embedding-app-origin) - (format "ALLOW-FROM %s" (first-embedding-app-origin)) + {"X-Frame-Options" (if-let [eao (and (embed.settings/enable-embedding-interactive) + (embed.settings/embedding-app-origins-interactive))] + (format "ALLOW-FROM %s" (-> eao (str/split #" ") first)) "DENY")}) {;; Tell browser to block suspected XSS attacks "X-XSS-Protection" "1; mode=block" diff --git a/test/metabase/analytics/stats_test.clj b/test/metabase/analytics/stats_test.clj index ee7e4417ce034..53ad90dd3bafc 100644 --- a/test/metabase/analytics/stats_test.clj +++ b/test/metabase/analytics/stats_test.clj @@ -134,6 +134,7 @@ startup-time-millis 1234.0 google-auth-enabled false enable-embedding true + embedding-app-origin "localhost:8888" help-link :hidden application-logo-url "http://example.com/logo.png" application-favicon-url "http://example.com/favicon.ico" diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 72048c40e4a43..2a24208aca2d5 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -885,7 +885,7 @@ (testing "Ignore values of `enable_embedding` while creating a Card (this must be done via `PUT /api/card/:id` instead)" ;; should be ignored regardless of the value of the `enable-embedding` Setting. (doseq [enable-embedding? [true false]] - (mt/with-temporary-setting-values [enable-embedding enable-embedding?] + (mt/with-temporary-setting-values [enable-embedding-static enable-embedding?] (mt/with-model-cleanup [:model/Card] (is (=? {:enable_embedding false} (mt/user-http-request :crowberto :post 200 "card" {:name "My Card" @@ -1422,12 +1422,12 @@ (testing "PUT /api/card/:id" (t2.with-temp/with-temp [:model/Card card] (testing "If embedding is disabled, even an admin should not be allowed to update embedding params" - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (is (= "Embedding is not enabled." (mt/user-http-request :crowberto :put 400 (str "card/" (u/the-id card)) {:embedding_params {:abc "enabled"}}))))) - (mt/with-temporary-setting-values [enable-embedding true] + (mt/with-temporary-setting-values [enable-embedding-static true] (testing "Non-admin should not be allowed to update Card's embedding parms" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "card/" (u/the-id card)) @@ -2854,7 +2854,7 @@ (deftest test-that-we-can-fetch-a-list-of-embeddable-cards (testing "GET /api/card/embeddable" - (mt/with-temporary-setting-values [enable-embedding true] + (mt/with-temporary-setting-values [enable-embedding-static true] (t2.with-temp/with-temp [:model/Card _ {:enable_embedding true}] (is (= [{:name true, :id true}] (for [card (mt/user-http-request :crowberto :get 200 "card/embeddable")] diff --git a/test/metabase/api/dashboard_test.clj b/test/metabase/api/dashboard_test.clj index 0be9d3cc20657..fc441f54fdb33 100644 --- a/test/metabase/api/dashboard_test.clj +++ b/test/metabase/api/dashboard_test.clj @@ -2622,7 +2622,7 @@ (deftest fetch-embeddable-dashboards-test (testing "GET /api/dashboard/embeddable" (testing "Test that we can fetch a list of embeddable-accessible dashboards" - (mt/with-temporary-setting-values [enable-embedding true] + (mt/with-temporary-setting-values [enable-embedding-static true] (t2.with-temp/with-temp [Dashboard _ {:enable_embedding true}] (is (= [{:name true, :id true}] (for [dash (mt/user-http-request :crowberto :get 200 "dashboard/embeddable")] diff --git a/test/metabase/api/embed_test.clj b/test/metabase/api/embed_test.clj index 0ca3cc6df3eb0..047f743dc230a 100644 --- a/test/metabase/api/embed_test.clj +++ b/test/metabase/api/embed_test.clj @@ -105,7 +105,8 @@ ~@body))) (defmacro with-embedding-enabled-and-new-secret-key! {:style/indent 0} [& body] - `(mt/with-temporary-setting-values [~'enable-embedding true] + `(mt/with-temporary-setting-values [~'enable-embedding-static true + ~'enable-embedding-interactive true] (with-new-secret-key! ~@body))) @@ -1090,7 +1091,7 @@ (deftest endpoint-should-fail-if-embedding-is-disabled (is (= "Embedding is not enabled." (with-embedding-enabled-and-temp-card-referencing! :venues :name [card] - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (client/client :get 400 (field-values-url card (mt/id :venues :name)))))))) (deftest embedding-not-enabled-message @@ -1106,7 +1107,7 @@ (dropdown [card param-key & [entity-id]] (client/client :get 200 (format "embed/card/%s/params/%s/values" (card-token card nil entity-id) param-key)))] - (mt/with-temporary-setting-values [enable-embedding true] + (mt/with-temporary-setting-values [enable-embedding-static true] (with-new-secret-key! (api.card-test/with-card-param-values-fixtures [{:keys [card field-filter-card param-keys]}] (t2/update! :model/Card (:id field-filter-card) @@ -1223,7 +1224,7 @@ (deftest field-values-endpoint-should-fail-if-embedding-is-disabled (is (= "Embedding is not enabled." (with-embedding-enabled-and-temp-dashcard-referencing! :venues :name [dashboard] - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (client/client :get 400 (field-values-url dashboard (mt/id :venues :name)))))))) ;; Endpoint should fail if embedding is disabled for the Dashboard @@ -1259,7 +1260,7 @@ :value "33 T")))) (testing "Endpoint should fail if embedding is disabled" - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (is (= "Embedding is not enabled." (client/client :get 400 (field-search-url object (mt/id :venues :id) (mt/id :venues :name)) :value "33 T"))))) @@ -1304,7 +1305,7 @@ :value "10")))) (testing " ...or if embedding is disabled" - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (is (= "Embedding is not enabled." (client/client :get 400 (field-remapping-url object (mt/id :venues :id) (mt/id :venues :name) entity-id) @@ -1564,7 +1565,7 @@ (mt/dataset test-data (testing "GET /api/embed/pivot/card/:token/query" (testing "check that the endpoint doesn't work if embedding isn't enabled" - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (with-new-secret-key! (with-temp-card [card (api.pivots/pivot-card)] (is (= "Embedding is not enabled." @@ -1629,7 +1630,7 @@ (deftest pivot-dashcard-embedding-disabled-test (mt/dataset test-data - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (with-new-secret-key! (with-temp-dashcard [dashcard {:dash {:parameters []} :card (api.pivots/pivot-card) diff --git a/test/metabase/api/preview_embed_test.clj b/test/metabase/api/preview_embed_test.clj index 79d3024bb0acc..0938834fff580 100644 --- a/test/metabase/api/preview_embed_test.clj +++ b/test/metabase/api/preview_embed_test.clj @@ -35,7 +35,7 @@ (mt/user-http-request :rasta :get 403 (card-url card))))) (testing "check that the endpoint doesn't work if embedding isn't enabled" - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (is (= "Embedding is not enabled." (embed-test/with-temp-card [card] (mt/user-http-request :crowberto :get 400 (card-url card))))))) @@ -87,7 +87,7 @@ (mt/user-http-request :rasta :get 403 (card-query-url card))))) (testing "check that the endpoint doesn't work if embedding isn't enabled" - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (is (= "Embedding is not enabled." (mt/user-http-request :crowberto :get 400 (card-query-url card)))))) @@ -238,7 +238,7 @@ (mt/user-http-request :rasta :get 403 (dashboard-url dash))))) (testing "check that the endpoint doesn't work if embedding isn't enabled" - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (is (= "Embedding is not enabled." (mt/user-http-request :crowberto :get 400 (dashboard-url dash)))))) @@ -284,7 +284,7 @@ (testing "check that the endpoint doesn't work if embedding isn't enabled" (is (= "Embedding is not enabled." - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (mt/user-http-request :crowberto :get 400 (dashcard-url dashcard)))))) (testing "check that if embedding is enabled globally requests fail if they are signed with the wrong key" @@ -488,7 +488,7 @@ (testing "should fail if embedding is disabled" (is (= "Embedding is not enabled." - (mt/with-temporary-setting-values [enable-embedding false] + (mt/with-temporary-setting-values [enable-embedding-static false] (embed-test/with-new-secret-key! (mt/user-http-request :crowberto :get 400 (pivot-dashcard-url dashcard))))))) diff --git a/test/metabase/api/setting_test.clj b/test/metabase/api/setting_test.clj index cf29dd9f6d578..2620102a22ca8 100644 --- a/test/metabase/api/setting_test.clj +++ b/test/metabase/api/setting_test.clj @@ -1,5 +1,6 @@ (ns ^:mb/once metabase.api.setting-test (:require + [clojure.string :as str] [clojure.test :refer :all] [metabase.api.common.validation :as validation] [metabase.driver.h2 :as h2] @@ -7,7 +8,8 @@ [metabase.models.setting-test :as models.setting-test] [metabase.test :as mt] [metabase.test.fixtures :as fixtures] - [metabase.util.i18n :refer [deferred-tru]])) + [metabase.util.i18n :refer [deferred-tru]] + [metabase.util.log.capture :as log.capture])) (comment h2/keep-me) @@ -375,3 +377,14 @@ (mt/user-http-request :crowberto :put 204 "setting" {:test_setting_1 "GHI", :test_setting_2 "JKL"}) (is (= "GHI" (mt/user-http-request :crowberto :get 200 "setting/test_setting_1"))) (is (= "JKL" (mt/user-http-request :crowberto :get 200 "setting/test_setting_2"))))))))) + +(defsetting test-deprecated-setting + (deferred-tru "Setting to test deprecation warning.") + :deprecated "0.51.0" + :encryption :no) + +(deftest deprecation-warning-for-deprecated-setting-test + (log.capture/with-log-messages-for-level [warnings :warn] + (test-deprecated-setting! "hello") + (is (re-find #"Setting test-deprecated-setting is deprecated as of Metabase 0.51.0" + (str/join " " (map :message (warnings))))))) diff --git a/test/metabase/db/schema_migrations_test.clj b/test/metabase/db/schema_migrations_test.clj index f15d80faa1738..7fb576feca193 100644 --- a/test/metabase/db/schema_migrations_test.clj +++ b/test/metabase/db/schema_migrations_test.clj @@ -2627,3 +2627,87 @@ (map #(select-keys % [:collection_id :perm_type :perm_value :object])))))) (testing "the invalid permissions (for a nonexistent table) were deleted" (is (empty? (t2/select :model/Permissions :object [:in [nonexistent-path nonexistent-read-path]])))))))) + +(deftest populate-enabled-embedding-settings-works + (testing "Check that embedding settings are nil when enable-embedding is nil" + (impl/test-migrations ["v51.2024-09-26T03:01:00" "v51.2024-09-26T03:03:00"] [migrate!] + (t2/delete! :model/Setting :key "enable-embedding") + (migrate!) + (is (= nil (t2/select-one :model/Setting :key "enable-embedding-interactive"))) + (is (= nil (t2/select-one :model/Setting :key "enable-embedding-static"))) + (is (= nil (t2/select-one-fn :value :model/Setting :key "enable-embedding-sdk"))))) + (testing "Check that embedding settings are true when enable-embedding is true" + (impl/test-migrations ["v51.2024-09-26T03:01:00" "v51.2024-09-26T03:03:00"] [migrate!] + (t2/delete! :model/Setting :key "enable-embedding") + (t2/insert! :model/Setting {:key "enable-embedding" :value "true"}) + (migrate!) + (is (= "true" (t2/select-one-fn :value :model/Setting :key "enable-embedding-interactive"))) + (is (= "true" (t2/select-one-fn :value :model/Setting :key "enable-embedding-static"))) + (is (= "true" (t2/select-one-fn :value :model/Setting :key "enable-embedding-sdk"))))) + (testing "Check that embedding settings are false when enable-embedding is false" + (impl/test-migrations ["v51.2024-09-26T03:01:00" "v51.2024-09-26T03:03:00"] [migrate!] + (t2/delete! :model/Setting :key "enable-embedding") + (t2/insert! :model/Setting {:key "enable-embedding" :value "false"}) + (migrate!) + (is (= "false" (t2/select-one-fn :value :model/Setting :key "enable-embedding-interactive"))) + (is (= "false" (t2/select-one-fn :value :model/Setting :key "enable-embedding-static"))) + (is (= "false" (t2/select-one-fn :value :model/Setting :key "enable-embedding-sdk")))))) + +(deftest populate-enabled-embedding-settings-encrypted-works + (testing "With encryption turned on > " + (mt/with-temp-env-var-value! [MB_ENCRYPTION_SECRET_KEY "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"] + (testing "Check that embedding settings are nil when enable-embedding is nil" + (impl/test-migrations ["v51.2024-09-26T03:01:00" "v51.2024-09-26T03:03:00"] [migrate!] + (t2/delete! :model/Setting :key "enable-embedding") + (migrate!) + (is (= nil (t2/select-one :model/Setting :key "enable-embedding-interactive"))) + (is (= nil (t2/select-one :model/Setting :key "enable-embedding-static"))) + (is (= nil (t2/select-one :model/Setting :key "enable-embedding-sdk"))))) + (testing "Check that embedding settings are true when enable-embedding is true" + (impl/test-migrations ["v51.2024-09-26T03:01:00" "v51.2024-09-26T03:03:00"] [migrate!] + (t2/delete! :model/Setting :key "enable-embedding") + (t2/insert! :model/Setting {:key "enable-embedding" :value "true"}) + (migrate!) + (is (= "true" (t2/select-one-fn :value :model/Setting :key "enable-embedding-interactive"))) + (is (= "true" (t2/select-one-fn :value :model/Setting :key "enable-embedding-static"))) + (is (= "true" (t2/select-one-fn :value :model/Setting :key "enable-embedding-sdk"))))) + (testing "Check that embedding settings are false when enable-embedding is false" + (impl/test-migrations ["v51.2024-09-26T03:01:00" "v51.2024-09-26T03:03:00"] [migrate!] + (t2/delete! :model/Setting :key "enable-embedding") + (t2/insert! :model/Setting {:key "enable-embedding" :value "false"}) + (migrate!) + (is (= "false" (t2/select-one-fn :value :model/Setting :key "enable-embedding-interactive"))) + (is (= "false" (t2/select-one-fn :value :model/Setting :key "enable-embedding-static"))) + (is (= "false" (t2/select-one-fn :value :model/Setting :key "enable-embedding-sdk")))))))) + +(deftest populate-embedding-origin-settings-works + (testing "Check that embedding-origins are unset when embedding-app-origin is unset" + (impl/test-migrations "v51.2024-09-26T03:04:00" [migrate!] + (t2/delete! :model/Setting :key "embedding-app-origin") + (migrate!) + (is (= nil (t2/select-one :model/Setting :key "embedding-app-origins-interactive"))) + (is (= nil (t2/select-one :model/Setting :key "embedding-app-origins-sdk"))))) + (testing "Check that embedding-origins settings are propigated when embedding-app-origin is set to some value" + (impl/test-migrations "v51.2024-09-26T03:04:00" [migrate!] + (t2/delete! :model/Setting :key "embedding-app-origin") + (t2/insert! :model/Setting {:key "embedding-app-origin" :value "1.2.3.4:5555"}) + (is (= "1.2.3.4:5555" (t2/select-one-fn :value :model/Setting :key "embedding-app-origin"))) + (migrate!) + (is (= "1.2.3.4:5555" (t2/select-one-fn :value :model/Setting :key "embedding-app-origins-interactive")))))) + +(deftest populate-embedding-origin-settings-encrypted-works + (testing "With encryption turned on > " + (mt/with-temp-env-var-value! [MB_ENCRYPTION_SECRET_KEY "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"] + (testing "Check that embedding-origins are unset when embedding-app-origin is unset" + (impl/test-migrations "v51.2024-09-26T03:04:00" [migrate!] + (t2/delete! :model/Setting :key "embedding-app-origin") + (migrate!) + (is (= nil (t2/select-one :model/Setting :key "embedding-app-origins-interactive"))) + (is (= nil (t2/select-one :model/Setting :key "embedding-app-origins-sdk"))))) + (testing "Check that embedding-origins settings are propigated when embedding-app-origin is set to some value" + (impl/test-migrations "v51.2024-09-26T03:04:00" [migrate!] + (t2/delete! :model/Setting :key "embedding-app-origin") + (t2/insert! :model/Setting {:key "embedding-app-origin" :value "1.2.3.4:5555"}) + (is (= "1.2.3.4:5555" (t2/select-one-fn :value :model/Setting :key "embedding-app-origin"))) + (migrate!) + (is (= "1.2.3.4:5555" (t2/select-one-fn :value :model/Setting :key "embedding-app-origins-interactive")))))))) diff --git a/test/metabase/query_processor/streaming_test.clj b/test/metabase/query_processor/streaming_test.clj index 47ec7b7b31be9..f064f74a65eea 100644 --- a/test/metabase/query_processor/streaming_test.clj +++ b/test/metabase/query_processor/streaming_test.clj @@ -318,7 +318,7 @@ card-defaults {:dataset_query query, :public_uuid public-uuid, :enable_embedding true} user (or user :rasta)] (mt/with-temporary-setting-values [enable-public-sharing true - enable-embedding true] + enable-embedding-static true] (embed-test/with-new-secret-key! (t2.with-temp/with-temp [Card card (if viz-settings (assoc card-defaults :visualization_settings viz-settings) diff --git a/test/metabase/server/middleware/security_test.clj b/test/metabase/server/middleware/security_test.clj index c4f7796349f7f..03a23f569f6cc 100644 --- a/test/metabase/server/middleware/security_test.clj +++ b/test/metabase/server/middleware/security_test.clj @@ -5,6 +5,7 @@ [clojure.string :as str] [clojure.test :refer :all] [metabase.config :as config] + [metabase.embed.settings :as embed.settings] [metabase.server.middleware.security :as mw.security] [metabase.test :as mt] [metabase.test.util :as tu] @@ -37,19 +38,20 @@ (mt/with-premium-features #{:embedding} (testing "Frame ancestors from `embedding-app-origin` setting" (let [multiple-ancestors "https://*.metabase.com http://metabase.internal"] - (tu/with-temporary-setting-values [enable-embedding true - embedding-app-origin multiple-ancestors] + (tu/with-temporary-setting-values [enable-embedding-interactive true + embedding-app-origins-interactive multiple-ancestors] (is (= (str "frame-ancestors " multiple-ancestors) (csp-directive "frame-ancestors")))))) (testing "Frame ancestors is 'none' for nil `embedding-app-origin`" - (tu/with-temporary-setting-values [enable-embedding true + (tu/with-temporary-setting-values [enable-embedding-interactive true + embedding-app-origins-interactive nil embedding-app-origin nil] (is (= "frame-ancestors 'none'" (csp-directive "frame-ancestors"))))) (testing "Frame ancestors is 'none' if embedding is disabled" - (tu/with-temporary-setting-values [enable-embedding false + (tu/with-temporary-setting-values [enable-embedding-interactive false embedding-app-origin "https: http:"] (is (= "frame-ancestors 'none'" (csp-directive "frame-ancestors"))))))) @@ -57,14 +59,14 @@ (deftest xframeoptions-header-tests (mt/with-premium-features #{:embedding} (testing "`DENY` when embedding is disabled" - (tu/with-temporary-setting-values [enable-embedding false + (tu/with-temporary-setting-values [enable-embedding-interactive false embedding-app-origin "https://somesite.metabase.com"] (is (= "DENY" (x-frame-options-header))))) (testing "Only the first of multiple embedding origins are used in `X-Frame-Options`" (let [embedding-app-origins ["https://site1.metabase.com" "https://our_metabase.internal"]] - (tu/with-temporary-setting-values [enable-embedding true - embedding-app-origin (str/join " " embedding-app-origins)] + (tu/with-temporary-setting-values [enable-embedding-interactive true + embedding-app-origins-interactive (str/join " " embedding-app-origins)] (is (= (str "ALLOW-FROM " (first embedding-app-origins)) (x-frame-options-header)))))))) @@ -180,21 +182,39 @@ (is (true? (mw.security/approved-origin? "http://example.com:8080" "example.com:*")))) (testing "Should handle invalid origins" - (is (true? (mw.security/approved-origin? "http://example.com" " fpt://something http://example.com ://123 4"))))) - -(deftest test-access-control-headers? - (testing "Should always allow localhost:*" - (tu/with-temporary-setting-values [enable-embedding true - embedding-app-origin nil] - (is (= "http://localhost:8080" (get (mw.security/access-control-headers "http://localhost:8080") "Access-Control-Allow-Origin"))))) - - (testing "Should disable CORS when embedding is disabled" - (tu/with-temporary-setting-values [enable-embedding false - embedding-app-origin nil] - (is (= nil (get (mw.security/access-control-headers "http://localhost:8080") "Access-Control-Allow-Origin"))))) - - (testing "Should work with embedding-app-origin" - (mt/with-premium-features #{:embedding} - (tu/with-temporary-setting-values [enable-embedding true - embedding-app-origin "example.com"] - (is (= "https://example.com" (get (mw.security/access-control-headers "https://example.com") "Access-Control-Allow-Origin"))))))) + (is (true? (mw.security/approved-origin? "http://example.com" " fpt://something ://123 4 http://example.com"))))) + +(deftest test-access-control-headers + (mt/with-premium-features #{:embedding-sdk} + (testing "Should always allow localhost:*" + (tu/with-temporary-setting-values [enable-embedding-sdk true + embedding-app-origins-sdk "localhost:*"] + (is (= "http://localhost:8080" (-> "http://localhost:8080" + (mw.security/access-control-headers + (embed.settings/enable-embedding-sdk) + (embed.settings/embedding-app-origins-sdk)) + (get "Access-Control-Allow-Origin")))))) + + (testing "Should disable CORS when enable-embedding-sdk is disabled" + (tu/with-temporary-setting-values [enable-embedding-sdk false] + (is (= nil (get (mw.security/access-control-headers + "http://localhost:8080" + (embed.settings/enable-embedding-sdk) + (embed.settings/embedding-app-origins-sdk)) + "Access-Control-Allow-Origin")) + "Localhost is only permitted when `enable-embedding-sdk` is `true`.")) + (is (= nil (get (mw.security/access-control-headers + "http://1.2.3.4:5555" + false + "localhost:*") + "Access-Control-Allow-Origin")))) + + (testing "Should work with embedding-app-origin" + (mt/with-premium-features #{:embedding-sdk} + (tu/with-temporary-setting-values [enable-embedding-sdk true + embedding-app-origins-sdk "https://example.com"] + (is (= "https://example.com" + (get (mw.security/access-control-headers "https://example.com" + (embed.settings/enable-embedding-sdk) + (embed.settings/embedding-app-origins-sdk)) + "Access-Control-Allow-Origin"))))))))