Compare commits

...

32 Commits

Author SHA1 Message Date
Oisin Coveney
44199b4fe6 Add beta label 2024-09-27 18:36:41 +03:00
Oisin Coveney
aeb73c0ccf Merge branch 'sdk-launch-in-core-app' into 46970-milestone-2 2024-09-27 17:54:21 +03:00
Oisin Coveney
dadc072583 Merge branch 'master' into sdk-launch-in-core-app 2024-09-27 17:54:18 +03:00
Oisin Coveney
e7e2061cf6 Merge branch 'sdk-launch-in-core-app' into 46970-milestone-2 2024-09-27 15:04:52 +03:00
Oisin Coveney
21e4ab1db8 Merge branch 'master' into sdk-launch-in-core-app 2024-09-27 15:04:49 +03:00
bryan
7c568b1b70 Feature flag and settings for SDK part one (#47933)
Co-authored-by: Oisin Coveney <oisin@metabase.com>
2024-09-27 13:42:33 +03:00
Oisin Coveney
3a0096f0f9 set encryption:no for embedding-app-origin as a temp? fix 2024-09-26 14:46:58 +03:00
Oisin Coveney
a47e0e5556 Merge branch 'sdk-launch-in-core-app' into 46970-milestone-2 2024-09-26 14:12:14 +03:00
Oisin Coveney
c7c35e2cb0 Merge branch 'master' into 46970-milestone-2 2024-09-26 14:11:46 +03:00
Oisin Coveney
3ae4f895fa Merge branch 'master' into sdk-launch-in-core-app 2024-09-26 14:10:57 +03:00
Oisin Coveney
629f8df242 Merge branch 'master' into 46970-milestone-2 2024-09-26 13:37:51 +03:00
Mahatthana (Kelvin) Nomsawadi
03fca79f68 Update embedding admin settings copy (#47891)
Co-authored-by: Oisin Coveney <oisin@metabase.com>
2024-09-26 13:16:17 +03:00
Oisin Coveney
e51c5799ed Merge branch 'sdk-launch-in-core-app' into 46970-milestone-2 2024-09-25 18:23:06 +03:00
Oisin Coveney
3ac5f8a79f Merge branch 'master' into sdk-launch-in-core-app 2024-09-25 18:22:42 +03:00
Oisin Coveney
27d3c3abf5 Merge branch 'sdk-launch-in-core-app' into 46970-milestone-2 2024-09-25 17:29:00 +03:00
Nicolò Pretto
7a355c400a adds reference to embedding sdk on the embedding homepage (#47975) 2024-09-25 12:45:11 +02:00
Nicolò Pretto
c6fc30b121 empty commit to allow opening the pr 2024-09-25 12:09:26 +02:00
Oisin Coveney
8109e8bba9 Merge branch 'master' into 46970-milestone-2 2024-09-25 11:50:11 +03:00
Oisin Coveney
5bf7deb045 Merge branch 'master' into 46970-milestone-2 2024-09-24 11:50:14 +03:00
Mahatthana (Kelvin) Nomsawadi
0042464eaf Respect embedding feature flag for interactive toggle (#47883)
* Respect `embedding` feature flag for interactive toggle

* Fix settings inside interactive embedding not working
2024-09-12 14:28:10 +01:00
Mahatthana (Kelvin) Nomsawadi
e4397295e8 Add 2 more embedding toggle settings (#47770)
* Add 2 more embedding toggle settings

- static embedding
- interactive embedding

* Add 2 more embedding toggles to embedding settings page

* Makes spacing easier to understand

* Add static embed settings page

* Add interactive embedding settings page

* Make unit tests consistent

* Add missing static embedding assertions

* Fix import dependency causing EE plugins to load after it should

* Fix unit tests

* Fix embedding-smoketests.cy.*

* Update E2E snapshots following the previous logic

* Fix more E2E tests

* Revert BE change becuase it fails a tons of BE tests.

* Make git history cleaner

* Clarify todo comment
2024-09-12 11:59:27 +01:00
Mahatthana (Kelvin) Nomsawadi
7fbe16ecef Merge branch 'master' into 46970-milestone-1 2024-09-12 13:12:34 +07:00
Mahatthana (Kelvin) Nomsawadi
1ea1b1ccbd Merge branch 'master' into 46970-milestone-1 2024-09-11 20:27:31 +07:00
Mahatthana (Kelvin) Nomsawadi
e82748cb2e Add embedding SDK CORS tests (#47761)
* Add E2E CORS tests

* Test CORS with `embedding-app-origins-sdk`

* Make custom URL consistent

* Simplify the code, not introducing more variants to package.json
2024-09-11 14:26:35 +01:00
Mahatthana (Kelvin) Nomsawadi
bb54f11b1b Merge branch 'master' into 46970-milestone-1 2024-09-09 20:35:55 +07:00
Mahatthana (Kelvin) Nomsawadi
2593e2faa3 Introduce enable-embedding-sdk toggle (#47686)
* pass `embedding-sdk` flag in `token-features`

* Add the embedding SDK toggle to the SDK card

* Add embedding SDK toggle to its settings page

* Enable the CORS origin input based on the SDK enable setting

* Fix BE tests

* Correct token feature `embedding_sdk` name

* fix unit tests

* Add embedding SDk toggle tests

* Add embedding SDK settings page toggle tests

* Hook up the new settingss, feature flag together

- feature flag `embedding-sdk`
- setting `enable-embedding-sdk`
- setting `embedding-app-origins-sdk`
2024-09-09 14:35:22 +01:00
Mahatthana (Kelvin) Nomsawadi
60405632ab Merge branch 'master' into 46970-milestone-1 2024-09-09 14:23:34 +07:00
Mahatthana (Kelvin) Nomsawadi
fbdd822ceb Merge branch 'master' into 46970-milestone-1 2024-09-06 20:46:13 +07:00
Mahatthana (Kelvin) Nomsawadi
2cd2e9da48 Merge branch 'master' into 46970-milestone-1 2024-09-05 16:46:46 +07:00
Mahatthana (Kelvin) Nomsawadi
3ec81d963a Add embedding SDK admin settings tests (#47519)
* Add static embed tests

* Consolidate embedding tests

* Consolidate embedding tests

* Consolidate interactive embed unit tests

* Add embedding SDK card tests

* Simplify tests

* Add embedding SDK tests

* Share the same repeated types

* Fix test name typo
2024-09-04 15:23:58 +01:00
Mahatthana (Kelvin) Nomsawadi
4280bfa352 Merge branch 'master' into 46970-milestone-1 2024-09-04 17:03:40 +07:00
Mahatthana (Kelvin) Nomsawadi
2852c8254a Add a new SDK setting page and it's dependencies (#47189)
* Add a new SDK setting page and it's dependencies

* Migrate to use `component` for admin settings

* [WIP] add an SDK setting using component API

* Simplify admin code

* Refactor the use of `getSettings`

* Make updating admin setting easier

* Add a new `embedding-app-origins-sdk` setting

* Fix embedding card and copy

* Fix static and interactive embedding icon not using custom brand color

* Update embedding cards copy

* Fig Figma reference link

* Fix disabled text and placeholder color

* Disable SDK origins according to a condition

* Fix type error

* Fix unit tests 1/*

* Fix unit tests 2/2

* Fix unit tests 3/3

* Fix BE tests

* Revert the copy and order of embedding cards back

* Fix E2E tests

* Fix embedding SDK breadcremb

* Revert "Make updating admin setting easier"

This reverts commit 46506ede91442aa5dd2e6e0e0151e23e440ea958.

* Revert "Refactor the use of `getSettings`"

This reverts commit b644928a4181fce4b2db5c4127f470d738f64227.

* Revert "Simplify admin code"

This reverts commit 14b4ffa4a1d97054e8fffde7ba3181d33d962367.

* Revert all settings changes to not interfere with the new project

* Remove unused export

* Update color mapping
2024-09-03 18:13:39 +01:00
91 changed files with 3484 additions and 807 deletions

View File

@@ -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",

View File

@@ -0,0 +1,11 @@
import jwt from "jsonwebtoken";
export function signJwt({
payload,
secret,
}: {
payload: Record<string, string | number>;
secret: string;
}): string {
return jwt.sign(payload, secret);
}

View File

@@ -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
});
});
});

View File

@@ -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 = {

View File

@@ -58,23 +58,12 @@ 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
assertLinkMatchesUrl("upgrade to a paid plan", upgradeUrl);
@@ -88,7 +77,12 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
});
cy.log("Standalone embeds page");
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(
@@ -98,7 +92,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.");
@@ -111,7 +105,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" })
@@ -310,7 +304,9 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => {
});
function resetEmbedding() {
cy.request("PUT", "/api/setting/enable-embedding", { value: false });
cy.request("PUT", "/api/setting/enable-embedding-static", {
value: false,
});
cy.request("PUT", "/api/setting/embedding-secret-key", {
value: null,
});

View File

@@ -1,17 +1,19 @@
(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]
[metabase.util.log.capture :as log.capture]
[toucan2.core :as t2]))
(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-origin "https://example.com"
enable-embedding 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
@@ -19,13 +21,45 @@
"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"})
(is (= [{:data (merge expected-payload {"event" "embedding_enabled"})
:user-id (str (mt/user->id :crowberto))}]
(-> (snowplow-test/pop-event-data-and-user-id!))))
(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 false]
(is (= [{:data
(merge expected-payload {"event" "embedding_disabled"})
:user-id (str (mt/user->id :crowberto))}]
(snowplow-test/pop-event-data-and-user-id!)))))))))))
(deftest deprecation-warning-for-enable-embedding-test
(mt/with-temporary-setting-values [enable-embedding "false"]
(log.capture/with-log-messages-for-level [warnings :warn]
(embed.settings/enable-embedding! true)
(is (re-find
#"Setting enable-embedding is deprecated as of Metabase 0.51.0 and may be removed in a future version."
(str/join " " (map :message (warnings))))))))
(def ^:private other-ip "1.2.3.4:1234")
(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))))))))

View File

@@ -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<typeof StaticDashboard> = args => {
return <StaticDashboard {...args} />;
};
export const Default = Template.bind({});
Default.args = {
dashboardId: DASHBOARD_ID,
};

View File

@@ -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) => (
<MetabaseProvider config={DEFAULT_CONFIG}>
<Story />
</MetabaseProvider>
);

View File

@@ -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;
}

View File

@@ -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;
@@ -13,20 +33,19 @@ interface SameSiteSelectWidgetProps {
onChange: (value: SessionCookieSameSite) => void;
setting: {
key: "session-cookie-samesite";
value?: SessionCookieSameSite;
defaultValue: SessionCookieSameSite;
options: Options[];
value: SessionCookieSameSite | null;
};
}
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({
</Menu.Target>
<Menu.Dropdown maw={"21rem"}>
{setting.options.map(({ value, name, description }) => (
{SAME_SITE_OPTIONS.map(({ value, name, description }) => (
<Menu.Item key="value" onClick={() => onChange(value)}>
<Text>{name}</Text>
<Text c="text-light">{description}</Text>

View File

@@ -0,0 +1,36 @@
import { t } from "ttag";
import { useEmbeddingSetting } from "metabase/admin/settings/components/EmbeddingSettings/hooks";
import SettingHeader from "metabase/admin/settings/components/SettingHeader";
import { SetByEnvVarWrapper } from "metabase/admin/settings/components/SettingsSetting";
import { SettingTextInput } from "metabase/admin/settings/components/widgets/SettingTextInput";
import { Box } from "metabase/ui";
import { EmbeddingAppOriginDescription } from "../EmbeddingAppOriginDescription";
const INTERACTIVE_EMBEDDING_ORIGINS_SETTING = {
key: "embedding-app-origins-interactive",
display_name: t`Authorized origins`,
description: <EmbeddingAppOriginDescription />,
placeholder: "https://*.example.com",
} as const;
export const AuthorizedOrigins = () => {
const [originsSetting, handleOriginsChange] = useEmbeddingSetting(
INTERACTIVE_EMBEDDING_ORIGINS_SETTING,
);
return (
<Box>
<SetByEnvVarWrapper setting={originsSetting}>
<SettingHeader id={originsSetting.key} setting={originsSetting} />
<SettingTextInput
id={originsSetting.key}
setting={originsSetting}
onChange={handleOriginsChange}
type="text"
/>
</SetByEnvVarWrapper>
</Box>
);
};

View File

@@ -0,0 +1,25 @@
import { t } from "ttag";
import {
EmbeddingSettingsPageView,
EmbeddingSettingsSwitch,
} from "metabase/admin/settings/components/EmbeddingSettings";
import { AuthorizedOrigins } from "./AuthorizedOrigins";
import { SameSiteCookieSetting } from "./SameSiteCookieSetting";
export const InteractiveEmbeddingSettings = () => (
<EmbeddingSettingsPageView
breadcrumbs={[
[t`Embedding`, "/admin/settings/embedding-in-other-applications"],
[t`Interactive embedding`],
]}
>
<EmbeddingSettingsSwitch
settingKey={"enable-embedding-interactive"}
switchLabel={t`Enable Interactive embedding`}
/>
<AuthorizedOrigins />
<SameSiteCookieSetting />
</EmbeddingSettingsPageView>
);

View File

@@ -0,0 +1,35 @@
import { t } from "ttag";
import { useEmbeddingSetting } from "metabase/admin/settings/components/EmbeddingSettings/hooks";
import SettingHeader from "metabase/admin/settings/components/SettingHeader";
import { SetByEnvVarWrapper } from "metabase/admin/settings/components/SettingsSetting";
import { Box } from "metabase/ui";
import {
EmbeddingAppSameSiteCookieDescription,
SameSiteSelectWidget,
} from "../EmbeddingAppSameSiteCookieDescription";
const SAME_SITE_SETTING = {
key: "session-cookie-samesite",
display_name: t`SameSite cookie setting`,
description: <EmbeddingAppSameSiteCookieDescription />,
widget: SameSiteSelectWidget,
} as const;
export const SameSiteCookieSetting = () => {
const [sameSiteSetting, handleChangeSameSite] =
useEmbeddingSetting(SAME_SITE_SETTING);
return (
<Box>
<SetByEnvVarWrapper setting={sameSiteSetting}>
<SettingHeader id={sameSiteSetting.key} setting={sameSiteSetting} />
<SameSiteSelectWidget
setting={sameSiteSetting}
onChange={handleChangeSameSite}
/>
</SetByEnvVarWrapper>
</Box>
);
};

View File

@@ -0,0 +1 @@
export * from "./InteractiveEmbeddingSettings";

View File

@@ -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: <EmbeddingAppOriginDescription />,
placeholder: "https://*.example.com",
type: "string",
getHidden: (_, derivedSettings) =>
!derivedSettings["enable-embedding"],
},
{
key: "session-cookie-samesite",
display_name: t`SameSite cookie setting`,
description: <EmbeddingAppSameSiteCookieDescription />,
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;
}

View File

@@ -16,6 +16,7 @@ import "./collections";
import "./content_verification";
import "./whitelabel";
import "./embedding";
import "./embedding-sdk";
import "./snippets";
import "./sharing";
import "./moderation";

View File

@@ -27,6 +27,7 @@ export interface EnterpriseSettings extends Settings {
"ee-openai-api-key"?: string;
"ee-openai-model"?: string;
"saml-user-provisioning-enabled?"?: boolean;
"saml-identity-provider-uri"?: boolean;
"scim-enabled"?: boolean | null;
"scim-base-url"?: string;
"send-new-sso-user-admin-email?"?: boolean;

View File

@@ -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";

View File

@@ -131,7 +131,7 @@ export const createMockTokenFeatures = (
export const createMockSettingDefinition = (
opts?: Partial<SettingDefinition>,
): SettingDefinition => ({
key: "key",
key: "active-users-count",
env_name: "",
is_env_setting: false,
value: null,
@@ -168,7 +168,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,

View File

@@ -1,3 +1,10 @@
import type { ReactNode } from "react";
import type {
EnterpriseSettingKey,
EnterpriseSettings,
} from "metabase-enterprise/settings/types";
export interface FormattingSettings {
"type/Temporal"?: DateFormattingSettings;
"type/Number"?: NumberFormattingSettings;
@@ -181,12 +188,17 @@ export type PasswordComplexity = {
export type SessionCookieSameSite = "lax" | "strict" | "none";
export interface SettingDefinition {
key: string;
export interface SettingDefinition<
Key extends EnterpriseSettingKey = EnterpriseSettingKey,
> {
key: Key;
env_name?: string;
is_env_setting: boolean;
value?: unknown;
default?: unknown;
value: EnterpriseSettings[Key] | null;
default?: EnterpriseSettings[Key];
display_name?: string;
description?: string | ReactNode;
placeholder?: string;
}
export interface OpenAiModel {
@@ -210,6 +222,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;
@@ -290,6 +305,8 @@ interface PublicSettings {
"ee-ai-features-enabled"?: boolean;
"email-configured?": boolean;
"embedding-app-origin": string;
"embedding-app-origins-sdk": string | null;
"embedding-app-origins-interactive": string | null;
"enable-enhancements?": boolean;
"enable-password-login": boolean;
engines: Record<string, Engine>;

View File

@@ -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({

View File

@@ -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"),
);
};

View File

@@ -1,54 +1,439 @@
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(screen.getByRole("button", { name: "Manage" })).toBeDisabled();
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;
});
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(
screen.getByRole("link", { name: "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 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 +443,157 @@ describe("[OSS] embedding settings", () => {
embeddingSettingsUrl,
);
});
it("should have a learn more button for interactive embedding", async () => {
await setupEmbedding({
settingValues: { "enable-embedding": false },
});
expect(
screen.getByRole("link", { name: "Learn More" }),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Learn More" })).toHaveProperty(
"href",
"https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings",
);
});
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(
"href",
"https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings",
);
});
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 },
});
expect(
screen.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",
);
});
});
});
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();
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",
);
});
const location = history.getCurrentLocation();
expect(location.pathname).toEqual(staticEmbeddingSettingsUrl);
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,
});
it("should not allow going to interactive embedding settings page", async () => {
const { history } = await setupEmbedding({
settingValues: { "enable-embedding": true },
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 },
});
expect(
screen.queryByRole("button", { name: "Configure" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Learn More" }),
).toBeInTheDocument();
act(() => {
history.push(interactiveEmbeddingSettingsUrl);
});
expect(history.getCurrentLocation().pathname).toEqual(
embeddingSettingsUrl,
// 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(
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: "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=oss",
);
});
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/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss",
);
});
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=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();
});
});
});
});

View File

@@ -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(screen.getByRole("button", { name: "Manage" })).toBeDisabled();
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(
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;
});
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(
screen.getByRole("link", { name: "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 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 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,149 @@ describe("[EE, no token] embedding settings", () => {
embeddingSettingsUrl,
);
});
it("should have a learn more button for interactive embedding", async () => {
await setupEnterprise({
settingValues: { "enable-embedding": false },
});
expect(
screen.getByRole("link", { name: "Learn More" }),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Learn More" })).toHaveProperty(
"href",
"https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings",
);
});
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(
"href",
"https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings",
);
});
it("should link to https://www.metabase.com/blog/why-full-app-embedding", async () => {
await setupEnterprise({
settingValues: { "enable-embedding": false },
});
expect(
screen.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",
);
});
});
});
describe("when the embedding is enabled", () => {
it("should allow going to static embedding settings page", async () => {
const { history } = await setupEnterprise({
settingValues: { "enable-embedding": true },
});
await goToStaticEmbeddingSettings();
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",
);
});
const location = history.getCurrentLocation();
expect(location.pathname).toEqual(staticEmbeddingSettingsUrl);
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,
});
it("should not allow going to interactive embedding settings page", async () => {
const { history } = await setupEnterprise({
settingValues: { "enable-embedding": true },
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 },
});
expect(
screen.queryByRole("button", { name: "Configure" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Learn More" }),
).toBeInTheDocument();
act(() => {
history.push(interactiveEmbeddingSettingsUrl);
});
expect(history.getCurrentLocation().pathname).toEqual(
embeddingSettingsUrl,
// 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(
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: "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=starter",
);
});
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/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter",
);
});
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=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();
});
});
});
});

View File

@@ -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,677 @@ 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(
await screen.findByRole("button", { name: "Manage" }),
).toBeDisabled();
withinStaticEmbeddingCard.getByRole("heading", {
name: "Static embedding",
}),
).toBeInTheDocument();
expect(
withinStaticEmbeddingCard.getByText(/Use static embedding when/),
).toBeInTheDocument();
act(() => {
history.push(staticEmbeddingSettingsUrl);
});
expect(history.getCurrentLocation().pathname).toEqual(
embeddingSettingsUrl,
);
expect(
withinStaticEmbeddingCard.getByLabelText("Disabled"),
).not.toBeChecked();
expect(
withinStaticEmbeddingCard.getByLabelText("Disabled"),
).toBeEnabled();
});
it("should not prompt to upgrade to remove the Powered by text", async () => {
await setupPremium({
settingValues: { "enable-embedding": false },
});
const withinStaticEmbeddingCard = within(
screen.getByRole("article", {
name: "Static embedding",
}),
);
expect(
screen.queryByText("upgrade to a paid plan"),
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(
screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"),
).toBeDisabled();
expect(
screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"),
).toHaveValue("metabase-sdk.com");
});
});
});
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 not allow going to interactive settings page", async () => {
const { history } = await setupPremium({
settingValues: { "enable-embedding": false },
});
it("should show info about interactive embedding", async () => {
const withinInteractiveEmbeddingCard = within(
screen.getByRole("article", {
name: "Interactive embedding",
}),
);
expect(
await screen.findByRole("button", { name: "Configure" }),
).toBeDisabled();
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();
act(() => {
history.push(interactiveEmbeddingSettingsUrl);
});
expect(history.getCurrentLocation().pathname).toEqual(
embeddingSettingsUrl,
// 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 link to quickstart for interactive embedding", async () => {
await setupPremium({
settingValues: {
"enable-embedding": false,
version: { tag: "v1.49.3" },
},
});
expect(getQuickStartLink()).toBeInTheDocument();
expect(getQuickStartLink()).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 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(
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 },
});
await goToStaticEmbeddingSettings();
describe("when static embedding is enabled", () => {
let history: History;
const location = history.getCurrentLocation();
expect(location.pathname).toEqual(staticEmbeddingSettingsUrl);
beforeEach(async () => {
history = (
await setupPremium({
settingValues: { "enable-embedding-static": true },
})
).history;
});
it("should allow going to interactive embedding settings page", async () => {
const { history } = await setupPremium({
settingValues: { "enable-embedding": true },
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(
withinStaticEmbeddingCard.getByLabelText("Enabled"),
).toBeChecked();
expect(
withinStaticEmbeddingCard.getByLabelText("Enabled"),
).toBeEnabled();
});
await goToInteractiveEmbeddingSettings();
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();
});
const location = history.getCurrentLocation();
expect(location.pathname).toEqual(interactiveEmbeddingSettingsUrl);
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 setupPremium({
settingValues: {
"enable-embedding-sdk": true,
"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("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: "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");
});
});
});
describe("when interactive embedding is enabled", () => {
let history: History;
beforeEach(async () => {
history = (
await setupPremium({
settingValues: {
"enable-embedding-interactive": true,
"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("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/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,
);
});
});
});
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();
});
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",
);
});
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" }),
);
});
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();
});
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");
});
});
});
});

View File

@@ -1,10 +1,11 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
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 type { SettingKey, Settings } from "metabase-types/api";
import {
createMockSettingDefinition,
createMockSettings,
createMockTokenFeatures,
} from "metabase-types/api/mocks";
@@ -13,19 +14,29 @@ import { setup } from "../setup";
export type SetupOpts = {
settingValues?: Partial<Settings>;
isHosted?: boolean;
hasEmbeddingFeature?: boolean;
hasEnterprisePlugins?: boolean;
};
export const setupEmbedding = async ({
settingValues,
isHosted = false,
hasEmbeddingFeature = false,
hasEnterprisePlugins = false,
}: SetupOpts) => {
const returnedValue = await setup({
settings: Object.entries(settingValues ?? {}).map(([key, value]) => {
return createMockSettingDefinition({
key: key as SettingKey,
value,
});
}),
settingValues: createMockSettings(settingValues),
tokenFeatures: createMockTokenFeatures({
hosting: isHosted,
embedding: hasEmbeddingFeature,
embedding_sdk: hasEmbeddingFeature,
}),
hasEnterprisePlugins,
});
@@ -38,16 +49,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 =

View File

@@ -0,0 +1,3 @@
import type { setupEmbedding } from "./setup";
export type History = Awaited<ReturnType<typeof setupEmbedding>>["history"];

View File

@@ -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"),

View File

@@ -0,0 +1,58 @@
import { jt, t } from "ttag";
import ExternalLink from "metabase/core/components/ExternalLink";
import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
import { Box } from "metabase/ui";
import SettingHeader from "../../SettingHeader";
import { SetByEnvVarWrapper } from "../../SettingsSetting";
import { SettingTextInput } from "../../widgets/SettingTextInput";
import { useEmbeddingSetting, useMergeSetting } from "../hooks";
import { useEmbeddingSettingsLinks } from "./sdk";
export const CorsOriginInput = () => {
const isEE = PLUGIN_EMBEDDING_SDK.isEnabled();
const enableEmbeddingSdkSetting = useMergeSetting({
key: "enable-embedding-sdk",
});
const isEmbeddingSdkEnabled = Boolean(enableEmbeddingSdkSetting.value);
const canEditSdkOrigins = isEE && isEmbeddingSdkEnabled;
const { upgradeUrl } = useEmbeddingSettingsLinks();
const [sdkOriginsSetting, handleChangeSdkOrigins] = useEmbeddingSetting(
!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, ${(
<ExternalLink key="upgrade-url" href={upgradeUrl}>
{t`upgrade to Metabase Pro`}
</ExternalLink>
)} 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.`,
},
);
return (
<Box>
<SetByEnvVarWrapper setting={sdkOriginsSetting}>
<SettingHeader id={sdkOriginsSetting.key} setting={sdkOriginsSetting} />
<SettingTextInput
id={sdkOriginsSetting.key}
setting={sdkOriginsSetting}
onChange={handleChangeSdkOrigins}
type="text"
disabled={!canEditSdkOrigins}
/>
</SetByEnvVarWrapper>
</Box>
);
};

View File

@@ -0,0 +1,20 @@
import { jt, t } from "ttag";
import ExternalLink from "metabase/core/components/ExternalLink";
import { Text } from "metabase/ui";
import { useEmbeddingSettingsLinks } from "./sdk";
export const DocumentationCta = () => {
const { documentationUrl } = useEmbeddingSettingsLinks();
return (
<Text data-testid="sdk-documentation">
{jt`Check out the ${(
<ExternalLink key="sdk-doc" href={documentationUrl}>
{t`documentation`}
</ExternalLink>
)} for more.`}
</Text>
);
};

View File

@@ -0,0 +1,37 @@
import { t } from "ttag";
import { useSetting } from "metabase/common/hooks";
import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
import { EmbeddingSettingsPageView } from "../EmbeddingSettingsPageView";
import { EmbeddingSettingsSwitch } from "../EmbeddingSettingsSwitch";
import { CorsOriginInput } from "./CorsOriginInput";
import { DocumentationCta } from "./DocumentationCta";
import { GetStarted } from "./GetStarted";
import { SdkInfoAlert } from "./SdkInfoAlert";
import { VersionPinning } from "./VersionPinning";
export const EmbeddingSdkSettings = () => {
const isEE = PLUGIN_EMBEDDING_SDK.isEnabled();
const isHosted = useSetting("is-hosted?");
return (
<EmbeddingSettingsPageView
breadcrumbs={[
[t`Embedding`, "/admin/settings/embedding-in-other-applications"],
[t`Embedding SDK for React`],
]}
>
<EmbeddingSettingsSwitch
settingKey={"enable-embedding-sdk"}
switchLabel={t`Enable Embedded analytics SDK`}
/>
<SdkInfoAlert />
<GetStarted />
<CorsOriginInput />
{isEE && isHosted && <VersionPinning />}
<DocumentationCta />
</EmbeddingSettingsPageView>
);
};

View File

@@ -0,0 +1,38 @@
import { t } from "ttag";
import ExternalLink from "metabase/core/components/ExternalLink";
import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
import { Box, Button } from "metabase/ui";
import SettingHeader from "../../SettingHeader";
import { useEmbeddingSettingsLinks } from "./sdk";
export const GetStarted = () => {
const isEE = PLUGIN_EMBEDDING_SDK.isEnabled();
const { quickStartUrl } = useEmbeddingSettingsLinks();
return (
<Box>
<SettingHeader
id="get-started"
setting={
isEE
? {
display_name: t`Get started`,
}
: {
display_name: t`Try Embedded analytics SDK`,
description: t`Use the SDK with API keys for development.`,
}
}
/>
<Button
variant="outline"
component={ExternalLink}
href={quickStartUrl}
>{t`Check out the Quick Start`}</Button>
</Box>
);
};

View File

@@ -0,0 +1,53 @@
import { match } from "ts-pattern";
import { jt, t } from "ttag";
import { useSetting } from "metabase/common/hooks";
import ExternalLink from "metabase/core/components/ExternalLink";
import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins";
import { Alert, Icon, Text } from "metabase/ui";
import { useEmbeddingSettingsLinks } from "./sdk";
export const SdkInfoAlert = () => {
const { implementJwtUrl, switchMetabaseBinariesUrl, upgradeUrl } =
useEmbeddingSettingsLinks();
const isHosted = useSetting("is-hosted?");
const isEE = PLUGIN_EMBEDDING_SDK.isEnabled();
const baseText = t`You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, `;
const upgradeLink = (
<ExternalLink href={upgradeUrl}>{t`upgrade to Metabase Pro`}</ExternalLink>
);
const jwtLink = (
<ExternalLink href={implementJwtUrl}>{t`implement JWT SSO`}</ExternalLink>
);
const alertText = match({ isEE, isHosted })
.with({ isEE: false, isHosted: false }, () => {
const switchLink = (
<ExternalLink
href={switchMetabaseBinariesUrl}
>{t`switch Metabase binaries`}</ExternalLink>
);
return jt`${baseText}${switchLink}, ${upgradeLink} and ${jwtLink}.`;
})
.with(
{ isEE: false, isHosted: true },
() => jt`${baseText}${upgradeLink} and ${jwtLink}.`,
)
.otherwise(() => jt`${baseText}${jwtLink}.`);
return (
<Alert
icon={<Icon color="var(--mb-color-text-secondary)" name="info_filled" />}
bg="var(--mb-color-background-info)"
style={{ borderColor: "var(--mb-color-border)" }}
variant="outline"
px="lg"
py="md"
maw={620}
>
<Text size="sm">{alertText}</Text>
</Alert>
);
};

View File

@@ -0,0 +1,26 @@
import { t } from "ttag";
import ExternalLink from "metabase/core/components/ExternalLink";
import { Box, Button, Icon } from "metabase/ui";
import SettingHeader from "../../SettingHeader";
export const VersionPinning = () => (
<Box>
<SettingHeader
id="version-pinning"
setting={{
display_name: t`Version pinning`,
description: t`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.`,
}}
/>
<Button
compact
variant="outline"
leftIcon={<Icon size={12} name="mail" aria-hidden />}
component={ExternalLink}
fz="0.75rem"
href="mailto:help@metabase.com"
>{t`Request version pinning`}</Button>
</Box>
);

View File

@@ -0,0 +1 @@
export * from "./EmbeddingSdkSettings";

View File

@@ -0,0 +1,52 @@
import { useDocsUrl } from "metabase/common/hooks";
import { getPlan } from "metabase/common/utils/plan";
import { useSelector } from "metabase/lib/redux";
import {
getLearnUrl,
getSetting,
getUpgradeUrl,
} from "metabase/selectors/settings";
export const useEmbeddingSettingsLinks = () => {
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 quickStartUrl = addUtmToLink("https://metaba.se/sdk-quick-start");
const { url: activationUrl } = useDocsUrl(
"paid-features/activating-the-enterprise-edition",
);
const switchMetabaseBinariesUrl = addUtmToLink(activationUrl);
const implementJwtUrl = addUtmToLink(
getLearnUrl("metabase-basics/embedding/securing-embeds"),
);
const documentationUrl = addUtmToLink("https://metaba.se/sdk-docs");
const upgradeUrl = useSelector(state =>
getUpgradeUrl(state, {
utm_campaign: "embedding-sdk",
utm_content: "embedding-sdk-admin",
}),
);
return {
quickStartUrl,
switchMetabaseBinariesUrl,
implementJwtUrl,
documentationUrl,
upgradeUrl,
};
};

View File

@@ -0,0 +1,32 @@
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";
export const EmbeddingSettings = () => (
<Stack spacing="2.5rem" px="md" pt="sm">
<Box data-testid="enable-embedding-setting">
<SettingHeader
id="enable-embedding"
setting={{
display_name: t`Embedding`,
}}
/>
<Stack spacing={"md"} className={CS.textMeasure}>
<Text lh={1.5}>
{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.`}
</Text>
</Stack>
</Box>
<StaticEmbeddingOptionCard />
<EmbeddingSdkOptionCard />
<InteractiveEmbeddingOptionCard />
</Stack>
);

View File

@@ -0,0 +1,16 @@
import type { PropsWithChildren } from "react";
import Breadcrumbs from "metabase/components/Breadcrumbs";
import { Stack } from "metabase/ui";
export const EmbeddingSettingsPageView = ({
children,
breadcrumbs,
}: PropsWithChildren<{
breadcrumbs: string[][];
}>) => (
<Stack spacing="2.5rem" px="md" pt="sm">
<Breadcrumbs size="large" crumbs={breadcrumbs} />
{children}
</Stack>
);

View File

@@ -0,0 +1,29 @@
import { t } from "ttag";
import { Switch, Text } from "metabase/ui";
import type { SettingKey } from "metabase-types/api";
import { useEmbeddingSetting } from "../hooks";
export const EmbeddingSettingsSwitch = ({
settingKey,
switchLabel,
}: {
settingKey: SettingKey;
switchLabel: string;
}) => {
const [setting, handleChange] = useEmbeddingSetting({ key: settingKey });
const isSettingEnabled = Boolean(setting.value);
return setting.is_env_setting ? (
<Text color="var(--mb-color-text-secondary)">{t`Set via environment variable`}</Text>
) : (
<Switch
label={switchLabel}
labelPosition="left"
size="sm"
checked={isSettingEnabled}
onChange={event => handleChange(event.currentTarget.checked)}
/>
);
};

View File

@@ -0,0 +1 @@
export * from "./EmbeddingSettingsSwitch";

View File

@@ -0,0 +1,5 @@
import RedirectWidget from "../../widgets/RedirectWidget";
export const InteractiveEmbeddingSettings = () => (
<RedirectWidget to="/admin/settings/embedding-in-other-applications" />
);

View File

@@ -0,0 +1 @@
export * from "./InteractiveEmbeddingSettings";

View File

@@ -0,0 +1,13 @@
import { t } from "ttag";
import { Box } from "metabase/ui";
import { SettingTitle } from "../../SettingHeader/SettingHeader.styled";
import { EmbeddedResources } from "../../widgets/PublicLinksListing";
export const ManageEmbeds = () => (
<Box data-testid="embedded-resources">
<SettingTitle>{t`Manage embeds`}</SettingTitle>
<EmbeddedResources />
</Box>
);

View File

@@ -0,0 +1,39 @@
import { t } from "ttag";
import { Box } from "metabase/ui";
import SettingHeader from "../../SettingHeader";
import { SetByEnvVarWrapper } from "../../SettingsSetting";
import SecretKeyWidget from "../../widgets/SecretKeyWidget";
import { useEmbeddingSetting } from "../hooks";
const EMBEDDING_SECRET_KEY_SETTING = {
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 const ManageSecretKey = () => {
const [embeddingSecretKeySetting, handleChangeEmbeddingSecretKey] =
useEmbeddingSetting(EMBEDDING_SECRET_KEY_SETTING);
return (
<Box data-testid="embedding-secret-key-setting">
<SetByEnvVarWrapper setting={embeddingSecretKeySetting}>
<SettingHeader
id="setting-embedding-secret-key"
setting={embeddingSecretKeySetting}
/>
<SecretKeyWidget
id="setting-embedding-secret-key"
onChange={handleChangeEmbeddingSecretKey}
setting={embeddingSecretKeySetting}
confirmation={{
header: t`Regenerate embedding key?`,
dialog: t`This will cause existing embeds to stop working until they are updated with the new key.`,
}}
/>
</SetByEnvVarWrapper>
</Box>
);
};

View File

@@ -0,0 +1,25 @@
import { t } from "ttag";
import { EmbeddingSettingsPageView } from "../EmbeddingSettingsPageView";
import { EmbeddingSettingsSwitch } from "../EmbeddingSettingsSwitch";
import { ManageEmbeds } from "./ManageEmbeds";
import { ManageSecretKey } from "./ManageSecretKey";
export function StaticEmbeddingSettings() {
return (
<EmbeddingSettingsPageView
breadcrumbs={[
[t`Embedding`, "/admin/settings/embedding-in-other-applications"],
[t`Static embedding`],
]}
>
<EmbeddingSettingsSwitch
settingKey="enable-embedding-static"
switchLabel={t`Enable Static embedding`}
/>
<ManageSecretKey />
<ManageEmbeds />
</EmbeddingSettingsPageView>
);
}

View File

@@ -0,0 +1 @@
export * from "./StaticEmbeddingSettings";

View File

@@ -0,0 +1,54 @@
import { useMemo } from "react";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type {
SettingDefinition,
SettingKey,
Settings,
} from "metabase-types/api";
import { updateSetting } from "../../settings";
type DisplaySetting<Key extends SettingKey> = {
key: Key;
[key: string]: any;
};
export type SettingWithValue<Key extends SettingKey> = {
key: Key;
value: Settings[Key];
[key: string]: any;
};
export function useMergeSetting<Key extends SettingKey>(
displaySetting: DisplaySetting<Key>,
): SettingDefinition<Key> {
const apiSetting = useSelector(state => state.admin.settings.settings).find(
setting => setting.key === displaySetting.key,
) as SettingDefinition<Key>;
const mergedSetting = useMemo(() => {
return {
...apiSetting,
...displaySetting,
};
}, [apiSetting, displaySetting]);
return mergedSetting;
}
export const useEmbeddingSetting = <Key extends SettingKey>(
displaySetting: DisplaySetting<Key>,
): [
SettingDefinition<Key>,
(value: SettingWithValue<Key>["value"]) => void,
] => {
const mergedSetting = useMergeSetting(displaySetting);
const dispatch = useDispatch();
const handleSettingChange = (value: SettingWithValue<Key>["value"]) => {
dispatch(updateSetting({ key: displaySetting.key, value }));
};
// used to match useState's get/set pattern with array values
return [mergedSetting, handleSettingChange];
};

View File

@@ -0,0 +1,6 @@
export { EmbeddingSettings } from "./EmbeddingSettings";
export { StaticEmbeddingSettings } from "./StaticEmbeddingSettings";
export { EmbeddingSdkSettings } from "./EmbeddingSdkSettings";
export { InteractiveEmbeddingSettings } from "./InteractiveEmbeddingSettings";
export { EmbeddingSettingsSwitch } from "./EmbeddingSettingsSwitch";
export { EmbeddingSettingsPageView } from "./EmbeddingSettingsPageView";

View File

@@ -0,0 +1,10 @@
import type { SettingKey, Settings } from "metabase-types/api";
type Setting<K extends SettingKey> = { key: K };
export interface AdminSettingComponentProps {
updateSetting: <K extends SettingKey>(
setting: Setting<K>,
value: Settings[K] | null,
) => Promise<void>;
}

View File

@@ -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 = {

View File

@@ -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;
`;

View File

@@ -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 (
<StyledCard compact>
<Paper
maw="40rem"
w="100%"
shadow="sm"
p="2.5rem"
component="article"
aria-labelledby={titleId}
>
{icon}
<Flex gap="md" mt="md" mb="sm" direction={"row"}>
<Title order={2}>{title}</Title>
{label && <Label>{label}</Label>}
<Flex gap="md" mt="md" mb="sm" direction={"row"} align="center">
<Title id={titleId} order={2}>
{title}
</Title>
{label}
</Flex>
<Text lh={"1.25rem"} mb={"lg"}>
{description}
@@ -47,114 +41,6 @@ function EmbeddingOption({
<Flex gap="lg" direction="column" align="flex-start">
{children}
</Flex>
</StyledCard>
</Paper>
);
}
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 ${(
<ExternalLink key="upgrade-link" href={upgradeUrl}>
{t`upgrade to a paid plan`}
</ExternalLink>
)} to remove it.`;
return (
<EmbeddingOption
icon={enabled ? <StaticEmbeddingOn /> : <StaticEmbeddingOff />}
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 && (
<Text size="sm" mt="xs" key="upgrade-text">
{upgradeText}
</Text>
)
}`}
>
<LinkButton
variant="default"
disabled={!enabled}
to={"/admin/settings/embedding-in-other-applications/standalone"}
>
{t`Manage`}
</LinkButton>
</EmbeddingOption>
);
};
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 (
<EmbeddingOption
icon={
!isEE || enabled ? (
<InteractiveEmbeddingOn />
) : (
<InteractiveEmbeddingOff />
)
}
title={t`Interactive embedding`}
label={t`PRO & ENTERPRISE`}
description={jt`Use interactive embedding when you want to ${(
<ExternalLink
href={`https://www.metabase.com/blog/why-full-app-embedding?utm_source=${plan}&utm_media=embed-settings`}
key="why-full-app-embedding"
>
{t`offer multi-tenant, self-service analytics`}
</ExternalLink>
)} and people want to create their own questions, dashboards, models, and more, all in their own data sandbox.`}
>
<BoldExternalLink
href={`${quickStartUrl}?utm_source=${plan}&utm_media=embed-settings`}
>
{t`Check out our Quick Start`}
</BoldExternalLink>
{isEE ? (
<LinkButton
to={"/admin/settings/embedding-in-other-applications/full-app"}
disabled={!enabled}
>
{t`Configure`}
</LinkButton>
) : (
<Button
component={ExternalLink}
href={`https://www.metabase.com/product/embedded-analytics?utm_source=${plan}&utm_media=embed-settings`}
>
{t`Learn More`}
</Button>
)}
</EmbeddingOption>
);
};
// 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 ? (
<Button disabled={disabled} {...buttonProps} />
) : (
<Link to={to}>
<Button {...buttonProps} />
</Link>
);
};

View File

@@ -0,0 +1,50 @@
import { t } from "ttag";
import { useSetting } from "metabase/common/hooks";
import { Badge } from "metabase/home/components/EmbedHomepage/Badge";
import { PLUGIN_EMBEDDING } from "metabase/plugins";
import { Flex, Group } from "metabase/ui";
import { EmbeddingOption } from "../EmbeddingOption";
import { LinkButton } from "../LinkButton";
import { SwitchWithSetByEnvVar } from "../SwitchWithSetByEnvVar";
import { SdkIcon } from "./SdkIcon";
export function EmbeddingSdkOptionCard() {
const isEmbeddingSdkEnabled = useSetting("enable-embedding-sdk");
const isEE = PLUGIN_EMBEDDING.isEnabled();
return (
<EmbeddingOption
icon={<SdkIcon disabled={!isEmbeddingSdkEnabled} />}
title={t`Embedded analytics SDK`}
label={
<Group spacing="sm">
<Badge
color="brand"
fz="sm"
px="sm"
py="xs"
uppercase
>{t`Pro and Enterprise`}</Badge>
<Badge
color="gray"
fz="sm"
px="sm"
py="xs"
uppercase
>{t`Beta`}</Badge>
</Group>
}
description={t`Interactive embedding with full, granular control. Embed and style individual Metabase components in your app, and tailor the experience to each person. Allows for CSS styling, custom user flows, event subscriptions, and more. Only available with SSO via JWT.`}
>
<Flex align="center" w="100%">
<LinkButton to={"/admin/settings/embedding-in-other-applications/sdk"}>
{!isEE ? t`Try it out` : t`Configure`}
</LinkButton>
<SwitchWithSetByEnvVar settingKey="enable-embedding-sdk" />
</Flex>
</EmbeddingOption>
);
}

View File

@@ -0,0 +1,28 @@
import {
type UseEmbeddingSettingsIconColorsProps,
useEmbeddingSettingsIconColors,
} from "../use-embedding-settings-icon-colors";
export const SdkIcon = ({ disabled }: UseEmbeddingSettingsIconColorsProps) => {
const { primary, secondary } = useEmbeddingSettingsIconColors({ disabled });
return (
<svg width="40" height="32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#a)">
<rect
x="1.25"
y="1.25"
width="37.5"
height="29.5"
rx="2.75"
stroke={secondary}
strokeWidth="2.5"
/>
<path
d="M20 14.11c1.03 0 1.87.84 1.87 1.89 0 1-.84 1.85-1.87 1.85-1.03 0-1.87-.85-1.87-1.85 0-1.05.84-1.89 1.87-1.89ZM15.37 24c.63.38 2.01-.2 3.6-1.7-.52-.59-1.03-1.23-1.51-1.9a22.965 22.965 0 0 1-2.4-.36c-.51 2.14-.32 3.61.31 3.96Zm.71-5.74-.29-.51c-.11.29-.22.58-.29.86.27.06.57.11.88.16l-.3-.51Zm6.54-.76.81-1.5-.81-1.5c-.3-.53-.62-1-.91-1.47C21.17 13 20.6 13 20 13c-.6 0-1.17 0-1.71.03-.29.47-.61.94-.91 1.47l-.81 1.5.81 1.5c.3.53.62 1 .91 1.47.54.03 1.11.03 1.71.03.6 0 1.17 0 1.71-.03.29-.47.61-.94.91-1.47ZM20 10.78c-.19.22-.39.45-.59.72h1.18c-.2-.27-.4-.5-.59-.72Zm0 10.44c.19-.22.39-.45.59-.72h-1.18c.2.27.4.5.59.72ZM24.62 8c-.62-.38-2 .2-3.59 1.7.52.59 1.03 1.23 1.51 1.9.82.08 1.63.2 2.4.36.51-2.14.32-3.61-.32-3.96Zm-.7 5.74.29.51c.11-.29.22-.58.29-.86-.27-.06-.57-.11-.88-.16l.3.51Zm1.45-7.05c1.47.84 1.63 3.05 1.01 5.63 2.54.75 4.37 1.99 4.37 3.68 0 1.69-1.83 2.93-4.37 3.68.62 2.58.46 4.79-1.01 5.63-1.46.84-3.45-.12-5.37-1.95-1.92 1.83-3.91 2.79-5.38 1.95-1.46-.84-1.62-3.05-1-5.63-2.54-.75-4.37-1.99-4.37-3.68 0-1.69 1.83-2.93 4.37-3.68-.62-2.58-.46-4.79 1-5.63 1.47-.84 3.46.12 5.38 1.95 1.92-1.83 3.91-2.79 5.37-1.95ZM25.08 16c.34.75.64 1.5.89 2.26 2.1-.63 3.28-1.53 3.28-2.26 0-.73-1.18-1.63-3.28-2.26-.25.76-.55 1.51-.89 2.26Zm-10.16 0c-.34-.75-.64-1.5-.89-2.26-2.1.63-3.28 1.53-3.28 2.26 0 .73 1.18 1.63 3.28 2.26.25-.76.55-1.51.89-2.26Zm9 2.26-.3.51c.31-.05.61-.1.88-.16-.07-.28-.18-.57-.29-.86l-.29.51Zm-2.89 4.04c1.59 1.5 2.97 2.08 3.59 1.7.64-.35.83-1.82.32-3.96-.77.16-1.58.28-2.4.36-.48.67-.99 1.31-1.51 1.9Zm-4.95-8.56.3-.51c-.31.05-.61.1-.88.16.07.28.18.57.29.86l.29-.51Zm2.89-4.04C17.38 8.2 16 7.62 15.37 8c-.63.35-.82 1.82-.31 3.96a23.018 23.018 0 0 1 2.4-.36c.48-.67.99-1.31 1.51-1.9Z"
fill={primary}
/>
</g>
</svg>
);
};

View File

@@ -0,0 +1 @@
export * from "./EmbeddingSdkOptionCard";

View File

@@ -1,22 +0,0 @@
<svg width="40" height="32" viewBox="0 0 40 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1030_4496)">
<rect x="1.25" y="1.25" width="37.5" height="29.5" rx="2.75" stroke="#F0F0F0" stroke-width="2.5"/>
<g opacity="0.5">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5267 17.4733L13 16L18.5267 14.5267L20 9L21.4733 14.5267L27 16L21.4733 17.4733L20 23L18.5267 17.4733Z" fill="#949AAB"/>
</g>
<g opacity="0.5">
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.3686 8.6314L27 8L29.3686 7.3686L30 5L30.6314 7.3686L33 8L30.6314 8.6314L30 11L29.3686 8.6314Z" fill="#949AAB"/>
</g>
<g opacity="0.5">
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.9738 24.0262L28 23.5L29.9738 22.9738L30.5 21L31.0262 22.9738L33 23.5L31.0262 24.0262L30.5 26L29.9738 24.0262Z" fill="#949AAB"/>
</g>
<g opacity="0.5">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.97383 11.0262L7 10.5L8.97383 9.97383L9.5 8L10.0262 9.97383L12 10.5L10.0262 11.0262L9.5 13L8.97383 11.0262Z" fill="#949AAB"/>
</g>
</g>
<defs>
<clipPath id="clip0_1030_4496">
<rect width="40" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,14 +0,0 @@
<svg width="40" height="32" viewBox="0 0 40 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1030_4461)">
<rect x="1.25" y="1.25" width="37.5" height="29.5" rx="2.75" stroke="#CBE2F7" stroke-width="2.5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5267 17.4733L13 16L18.5267 14.5267L20 9L21.4733 14.5267L27 16L21.4733 17.4733L20 23L18.5267 17.4733Z" fill="#509EE3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.3686 8.6314L27 8L29.3686 7.3686L30 5L30.6314 7.3686L33 8L30.6314 8.6314L30 11L29.3686 8.6314Z" fill="#509EE3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.9738 24.0262L28 23.5L29.9738 22.9738L30.5 21L31.0262 22.9738L33 23.5L31.0262 24.0262L30.5 26L29.9738 24.0262Z" fill="#509EE3"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.97383 11.0262L7 10.5L8.97383 9.97383L9.5 8L10.0262 9.97383L12 10.5L10.0262 11.0262L9.5 13L8.97383 11.0262Z" fill="#509EE3"/>
</g>
<defs>
<clipPath id="clip0_1030_4461">
<rect width="40" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,37 @@
import {
type UseEmbeddingSettingsIconColorsProps,
useEmbeddingSettingsIconColors,
} from "../use-embedding-settings-icon-colors";
export const InteractiveEmbeddingIcon = ({
disabled,
}: UseEmbeddingSettingsIconColorsProps) => {
const { primary, secondary } = useEmbeddingSettingsIconColors({ disabled });
return (
<svg width="40" height="32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_1030_4461)">
<rect
x="1.25"
y="1.25"
width="37.5"
height="29.5"
rx="2.75"
stroke={secondary}
strokeWidth="2.5"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.527 17.473 13 16l5.527-1.473L20 9l1.473 5.527L27 16l-5.527 1.473L20 23l-1.473-5.527ZM29.369 8.631 27 8l2.369-.631L30 5l.631 2.369L33 8l-2.369.631L30 11l-.631-2.369ZM29.974 24.026 28 23.5l1.974-.526L30.5 21l.526 1.974L33 23.5l-1.974.526L30.5 26l-.526-1.974ZM8.974 11.026 7 10.5l1.974-.526L9.5 8l.526 1.974L12 10.5l-1.974.526L9.5 13l-.526-1.974Z"
fill={primary}
/>
</g>
<defs>
<clipPath id="clip0_1030_4461">
<path fill="#fff" d="M0 0h40v32H0z" />
</clipPath>
</defs>
</svg>
);
};

View File

@@ -0,0 +1,95 @@
import { jt, t } from "ttag";
import { useSetting } from "metabase/common/hooks";
import { getPlan } from "metabase/common/utils/plan";
import ExternalLink from "metabase/core/components/ExternalLink";
import { Badge } from "metabase/home/components/EmbedHomepage/Badge";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_EMBEDDING } from "metabase/plugins";
import { getDocsUrl, getSetting } from "metabase/selectors/settings";
import { Box, Button, Flex, Icon, Text } from "metabase/ui";
import { EmbeddingOption } from "../EmbeddingOption";
import { LinkButton } from "../LinkButton";
import { SwitchWithSetByEnvVar } from "../SwitchWithSetByEnvVar";
import { InteractiveEmbeddingIcon } from "./InteractiveEmbeddingIcon";
export const InteractiveEmbeddingOptionCard = () => {
const isEE = PLUGIN_EMBEDDING.isEnabled();
const plan = useSelector(state =>
getPlan(getSetting(state, "token-features")),
);
const isInteractiveEmbeddingEnabled = useSetting(
"enable-embedding-interactive",
);
const quickStartUrl = useSelector(state =>
getDocsUrl(state, {
page: "embedding/interactive-embedding-quick-start-guide",
}),
);
const quickStartLinkWithUtm = `${quickStartUrl}?utm_source=${plan}&utm_media=embed-settings`;
return (
<EmbeddingOption
icon={
<InteractiveEmbeddingIcon
disabled={isEE && !isInteractiveEmbeddingEnabled}
/>
}
title={t`Interactive embedding`}
label={
<Badge
fz="sm"
px="sm"
py="xs"
color="brand"
uppercase
>{t`Pro and Enterprise`}</Badge>
}
description={jt`Use interactive embedding when you want to ${(
<ExternalLink
href={`https://www.metabase.com/blog/why-full-app-embedding?utm_source=${plan}&utm_media=embed-settings`}
key="why-full-app-embedding"
>
{t`offer multi-tenant, self-service analytics`}
</ExternalLink>
)} and people want to create their own questions, dashboards, models, and more, all in their own data sandbox.`}
>
<Text
fw="bold"
color="brand"
component="a"
pos="relative"
href={quickStartLinkWithUtm}
target="_blank"
>
{t`Check out our Quick Start`}{" "}
<Box ml="sm" top="2.5px" pos="absolute" component="span">
<Icon name="share" aria-hidden />
</Box>
</Text>
<Flex align="center" w="100%">
{isEE ? (
<LinkButton
to={"/admin/settings/embedding-in-other-applications/full-app"}
>
{t`Configure`}
</LinkButton>
) : (
<Button
component={ExternalLink}
href={`https://www.metabase.com/product/embedded-analytics?utm_source=${plan}&utm_media=embed-settings`}
>
{t`Learn More`}
</Button>
)}
<SwitchWithSetByEnvVar
settingKey="enable-embedding-interactive"
disabled={!isEE}
/>
</Flex>
</EmbeddingOption>
);
};

View File

@@ -0,0 +1 @@
export * from "./InteractiveEmbeddingOptionCard";

View File

@@ -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 ? (
<Button disabled={disabled} {...buttonProps} />
) : (
<Link to={to}>
<Button {...buttonProps} />
</Link>
);
};

View File

@@ -0,0 +1 @@
export * from "./LinkButton";

View File

@@ -1,8 +0,0 @@
<svg width="42" height="34" viewBox="0 0 42 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.25" y="1.25" width="37.5" height="29.5" rx="2.75" stroke="#F0F0F0" stroke-width="2.5"/>
<path opacity="0.5" d="M14 12C14 10.8954 14.8954 10 16 10H33C34.1046 10 35 10.8954 35 12V17H14V12Z" fill="#949AAB"/>
<path opacity="0.5" d="M14 19H23V28H16C14.8954 28 14 27.1046 14 26V19Z" fill="#949AAB"/>
<path opacity="0.5" d="M25 19H35V26C35 27.1046 34.1046 28 33 28H25V19Z" fill="#949AAB"/>
<path d="M5 10H11V28H7C5.89543 28 5 27.1046 5 26V10Z" fill="#F0F0F0"/>
<path d="M5 6C5 4.89543 5.89543 4 7 4H33C34.1046 4 35 4.89543 35 6V8H5V6Z" fill="#F0F0F0"/>
</svg>

Before

Width:  |  Height:  |  Size: 674 B

View File

@@ -1,8 +0,0 @@
<svg width="42" height="34" viewBox="0 0 42 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.25" y="1.25" width="37.5" height="29.5" rx="2.75" stroke="#CBE2F7" stroke-width="2.5"/>
<path d="M14 12C14 10.8954 14.8954 10 16 10H33C34.1046 10 35 10.8954 35 12V17H14V12Z" fill="#509EE3"/>
<path d="M14 19H23V28H16C14.8954 28 14 27.1046 14 26V19Z" fill="#509EE3"/>
<path d="M25 19H35V26C35 27.1046 34.1046 28 33 28H25V19Z" fill="#509EE3"/>
<path d="M5 10H11V28H7C5.89543 28 5 27.1046 5 26V10Z" fill="#CBE2F7"/>
<path d="M5 6C5 4.89543 5.89543 4 7 4H33C34.1046 4 35 4.89543 35 6V8H5V6Z" fill="#CBE2F7"/>
</svg>

Before

Width:  |  Height:  |  Size: 632 B

View File

@@ -0,0 +1,32 @@
import {
type UseEmbeddingSettingsIconColorsProps,
useEmbeddingSettingsIconColors,
} from "../use-embedding-settings-icon-colors";
export const StaticEmbeddingIcon = ({
disabled,
}: UseEmbeddingSettingsIconColorsProps) => {
const { primary, secondary } = useEmbeddingSettingsIconColors({ disabled });
return (
<svg width="42" height="34" fill="white" xmlns="http://www.w3.org/2000/svg">
<rect
x="1.25"
y="1.25"
width="37.5"
height="29.5"
rx="2.75"
stroke={secondary}
strokeWidth="2.5"
/>
<path
d="M14 12a2 2 0 0 1 2-2h17a2 2 0 0 1 2 2v5H14v-5ZM14 19h9v9h-7a2 2 0 0 1-2-2v-7ZM25 19h10v7a2 2 0 0 1-2 2h-8v-9Z"
fill={primary}
/>
<path
d="M5 10h6v18H7a2 2 0 0 1-2-2V10ZM5 6a2 2 0 0 1 2-2h26a2 2 0 0 1 2 2v2H5V6Z"
fill={secondary}
/>
</svg>
);
};

View File

@@ -0,0 +1,52 @@
import { jt, t } from "ttag";
import { useSetting } from "metabase/common/hooks";
import ExternalLink from "metabase/core/components/ExternalLink";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_EMBEDDING } from "metabase/plugins";
import { getUpgradeUrl } from "metabase/selectors/settings";
import { Flex, Text } from "metabase/ui";
import { EmbeddingOption } from "../EmbeddingOption";
import { LinkButton } from "../LinkButton";
import { SwitchWithSetByEnvVar } from "../SwitchWithSetByEnvVar";
import { StaticEmbeddingIcon } from "./StaticEmbeddingIcon";
export const StaticEmbeddingOptionCard = () => {
const isStaticEmbeddingEnabled = useSetting("enable-embedding-static");
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 ${(
<ExternalLink key="upgrade-link" href={upgradeUrl}>
{t`upgrade to a paid plan`}
</ExternalLink>
)} to remove it.`;
return (
<EmbeddingOption
icon={<StaticEmbeddingIcon disabled={!isStaticEmbeddingEnabled} />}
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 && (
<Text size="sm" mt="xs" key="upgrade-text">
{upgradeText}
</Text>
)
}`}
>
<Flex align="center" w="100%">
<LinkButton
variant="default"
to={"/admin/settings/embedding-in-other-applications/standalone"}
>
{t`Manage`}
</LinkButton>
<SwitchWithSetByEnvVar settingKey="enable-embedding-static" />
</Flex>
</EmbeddingOption>
);
};

View File

@@ -0,0 +1 @@
export * from "./StaticEmbeddingOptionCard";

View File

@@ -0,0 +1,43 @@
import type { ComponentProps } from "react";
import { t } from "ttag";
import { Switch, Text } from "metabase/ui";
import { useEmbeddingSetting } from "../../../EmbeddingSettings/hooks";
interface SwitchWithSetByEnvVarProps extends ComponentProps<typeof Switch> {
settingKey:
| "enable-embedding-static"
| "enable-embedding-sdk"
| "enable-embedding-interactive";
}
export function SwitchWithSetByEnvVar({
settingKey,
...switchProps
}: SwitchWithSetByEnvVarProps) {
const [setting, handleChange] = useEmbeddingSetting({ key: settingKey });
if (setting.is_env_setting) {
return (
<Text
ml="auto"
color="var(--mb-color-text-secondary)"
>{t`Set via environment variable`}</Text>
);
}
const isEnabled = Boolean(setting.value);
return (
<Switch
{...switchProps}
label={isEnabled ? t`Enabled` : t`Disabled`}
size="sm"
ml="auto"
labelPosition="left"
checked={isEnabled}
disabled={setting.is_env_setting ? true : switchProps.disabled}
onChange={event => handleChange(event.currentTarget.checked)}
/>
);
}

View File

@@ -0,0 +1 @@
export * from "./SwitchWithSetByEnvVar";

View File

@@ -1,4 +1,3 @@
export {
InteractiveEmbeddingOptionCard,
StaticEmbeddingOptionCard,
} from "./EmbeddingOption";
export { StaticEmbeddingOptionCard } from "./StaticEmbeddingOptionCard";
export { EmbeddingSdkOptionCard } from "./EmbeddingSdkOptionCard";
export { InteractiveEmbeddingOptionCard } from "./InteractiveEmbeddingOptionCard";

View File

@@ -0,0 +1,19 @@
export type UseEmbeddingSettingsIconColorsProps = {
disabled: boolean;
};
export const useEmbeddingSettingsIconColors = ({
disabled,
}: UseEmbeddingSettingsIconColorsProps) => {
if (disabled) {
return {
primary: "var(--mb-base-color-gray-30)",
secondary: "var(--mb-base-color-gray-10)",
};
}
return {
primary: "var(--mb-base-color-brand-40)",
secondary: "var(--mb-base-color-brand-20)",
};
};

View File

@@ -1,26 +0,0 @@
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import { Stack, Switch, Text } from "metabase/ui";
interface EmbeddingSwitchWidgetProps {
setting: { value: boolean | null };
onChange: (value: boolean) => void;
}
export const EmbeddingSwitchWidget = ({
setting,
onChange,
}: EmbeddingSwitchWidgetProps) => (
<Stack spacing={"md"} className={CS.textMeasure}>
<Text lh={1.5}>
{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.`}
</Text>
<Switch
labelPosition="left"
checked={Boolean(setting.value)}
label={<strong>{t`Enable Embedding`}</strong>}
onChange={e => onChange(e.target.checked)}
/>
</Stack>
);

View File

@@ -1 +0,0 @@
export { EmbeddingSwitchWidget } from "./EmbeddingSwitchWidget";

View File

@@ -11,7 +11,7 @@ import { FormSwitch } from "metabase/forms";
import { isDefaultGroup } from "metabase/lib/groups";
import { Icon } from "metabase/ui";
import SettingToggle from "../SettingToggle";
import { SettingToggle } from "../SettingToggle";
import AddMappingRow from "./AddMappingRow";
import {

View File

@@ -2,7 +2,7 @@
import { Component } from "react";
import { t } from "ttag";
import SettingToggle from "./SettingToggle";
import { SettingToggle } from "./SettingToggle";
const VERIFIED = "verified";
const CHECKING = "checking";

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { t } from "ttag";
import Confirm from "metabase/components/Confirm";
@@ -9,6 +10,7 @@ import { SettingInput } from "../SettingInput";
import { GenerateButton, SecretKeyWidgetRoot } from "./SecretKeyWidget.styled";
interface SecretKeyWidgetProps {
id?: string;
onChange: (token: string) => void;
setting: any;
confirmation: {
@@ -23,14 +25,22 @@ const SecretKeyWidget = ({
confirmation,
...rest
}: SecretKeyWidgetProps) => {
const [loading, setLoading] = useState(false);
const generateToken = async () => {
setLoading(true);
const result = await UtilApi.random_token();
onChange(result.token);
setLoading(false);
};
return (
<SecretKeyWidgetRoot>
<SettingInput setting={setting} onChange={onChange} {...(rest as any)} />
<SettingInput
loading={loading}
setting={setting}
onChange={onChange}
{...(rest as any)}
/>
{setting.value ? (
<Confirm
triggerClasses={CS.fullHeight}

View File

@@ -10,7 +10,7 @@ export interface SettingInputProps {
setting: {
key: string;
value: string | null;
default?: string;
default?: string | null;
placeholder?: string;
};
onChange: (value: Value) => void;
@@ -20,6 +20,7 @@ export interface SettingInputProps {
id?: string;
type: "text" | "password";
normalize?: (value: Value) => Value;
disabled?: boolean;
}
const identity = (value: Value) => value;
@@ -32,6 +33,7 @@ export const SettingTextInput = ({
id,
type = "text",
normalize = identity,
disabled,
}: SettingInputProps) => {
const [valueState, setValueState] = useState<string>(setting.value ?? "");
const changeHandler = (e: { target: HTMLInputElement }) => {
@@ -59,6 +61,7 @@ export const SettingTextInput = ({
placeholder={setting.placeholder}
onBlur={changeHandler}
autoFocus={autoFocus}
disabled={disabled}
/>
);
};

View File

@@ -1,20 +1,39 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import type { ReactNode } from "react";
import { t } from "ttag";
import Toggle from "metabase/core/components/Toggle";
import Tooltip from "metabase/core/components/Tooltip";
import CS from "metabase/css/core/index.css";
const SettingToggle = ({
import type { SettingElement } from "../../types";
type SettingToggleProps = {
disabled: boolean;
hideLabel: boolean;
id: string;
setting: SettingElement;
tooltip: ReactNode;
onChange: ((value: boolean) => void) | undefined;
onChangeSetting?: unknown;
reloadSettings?: unknown;
settingValues?: unknown;
};
export const SettingToggle = ({
disabled,
hideLabel,
id,
setting,
tooltip,
onChange,
// the following three props were being spread into the 'div', causing
// unknown prop errors so we're just keeping them here
onChangeSetting,
reloadSettings,
settingValues,
...props
}) => {
}: SettingToggleProps) => {
const value = setting.value == null ? setting.default : setting.value;
const on = value === true || value === "true";
return (
@@ -23,7 +42,7 @@ const SettingToggle = ({
<Toggle
id={id}
value={on}
onChange={!disabled ? () => onChange(!on) : null}
onChange={!disabled ? () => onChange?.(!on) : undefined}
disabled={disabled}
/>
</Tooltip>
@@ -35,5 +54,3 @@ const SettingToggle = ({
</div>
);
};
export default SettingToggle;

View File

@@ -3,14 +3,13 @@ import { t } from "ttag";
import _ from "underscore";
import { SMTPConnectionForm } from "metabase/admin/settings/components/Email/SMTPConnectionForm";
import Breadcrumbs from "metabase/components/Breadcrumbs";
import { DashboardSelector } from "metabase/components/DashboardSelector";
import MetabaseSettings from "metabase/lib/settings";
import { newVersionAvailable } from "metabase/lib/utils";
import {
PLUGIN_ADMIN_SETTINGS,
PLUGIN_ADMIN_SETTINGS_AUTH_TABS,
PLUGIN_ADMIN_SETTINGS_UPDATES,
PLUGIN_EMBEDDING,
PLUGIN_LLM_AUTODESCRIPTION,
} from "metabase/plugins";
import { refreshCurrentUser } from "metabase/redux/user";
@@ -23,25 +22,22 @@ import {
import { CloudPanel } from "./components/CloudPanel";
import { BccToggleWidget } from "./components/Email/BccToggleWidget";
import { SettingsEmailForm } from "./components/Email/SettingsEmailForm";
import {
EmbeddingSdkSettings,
EmbeddingSettings,
StaticEmbeddingSettings,
} from "./components/EmbeddingSettings";
import SettingsLicense from "./components/SettingsLicense";
import SettingsUpdatesForm from "./components/SettingsUpdatesForm/SettingsUpdatesForm";
import { UploadSettings } from "./components/UploadSettings";
import CustomGeoJSONWidget from "./components/widgets/CustomGeoJSONWidget";
import {
InteractiveEmbeddingOptionCard,
StaticEmbeddingOptionCard,
} from "./components/widgets/EmbeddingOption";
import { EmbeddingSwitchWidget } from "./components/widgets/EmbeddingSwitchWidget";
import FormattingWidget from "./components/widgets/FormattingWidget";
import HttpsOnlyWidget from "./components/widgets/HttpsOnlyWidget";
import {
EmbeddedResources,
PublicLinksActionListing,
PublicLinksDashboardListing,
PublicLinksQuestionListing,
} from "./components/widgets/PublicLinksListing";
import RedirectWidget from "./components/widgets/RedirectWidget";
import SecretKeyWidget from "./components/widgets/SecretKeyWidget";
import SettingCommaDelimitedInput from "./components/widgets/SettingCommaDelimitedInput";
import SiteUrlWidget from "./components/widgets/SiteUrlWidget";
import { NotificationSettings } from "./notifications/NotificationSettings";
@@ -428,100 +424,25 @@ export const ADMIN_SETTINGS_SECTIONS = {
key: "enable-embedding",
name: t`Embedding`,
order: 100,
settings: [
{
key: "enable-embedding",
display_name: t`Embedding`,
description: null,
widget: EmbeddingSwitchWidget,
},
{
key: "-static-embedding",
widget: StaticEmbeddingOptionCard,
},
{
key: "-interactive-embedding",
widget: InteractiveEmbeddingOptionCard,
},
],
component: EmbeddingSettings,
settings: [],
},
"embedding-in-other-applications/standalone": {
settings: [
{
key: "-breadcrumb",
widget: () => {
return (
<Breadcrumbs
size="large"
crumbs={[
[
t`Embedding`,
"/admin/settings/embedding-in-other-applications",
],
[t`Static embedding`],
]}
/>
);
},
},
{
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.`,
widget: SecretKeyWidget,
getHidden: (_, derivedSettings) => !derivedSettings["enable-embedding"],
props: {
confirmation: {
header: t`Regenerate embedding key?`,
dialog: t`This will cause existing embeds to stop working until they are updated with the new key.`,
},
},
},
{
key: "-embedded-resources",
display_name: t`Manage embeds`,
widget: EmbeddedResources,
getHidden: (_, derivedSettings) => !derivedSettings["enable-embedding"],
},
{
key: "-redirect-widget",
widget: () => (
<RedirectWidget to="/admin/settings/embedding-in-other-applications" />
),
getHidden: (_, derivedSettings) => derivedSettings["enable-embedding"],
},
],
component: StaticEmbeddingSettings,
settings: [],
},
"embedding-in-other-applications/sdk": {
component: EmbeddingSdkSettings,
settings: [],
},
"embedding-in-other-applications/full-app": {
settings: [
{
key: "-breadcrumbs",
widget: () => {
return (
<Breadcrumbs
size="large"
crumbs={[
[
t`Embedding`,
"/admin/settings/embedding-in-other-applications",
],
[t`Interactive embedding`],
]}
/>
);
},
},
{
key: "-redirect-widget",
widget: () => (
<RedirectWidget to="/admin/settings/embedding-in-other-applications" />
),
getHidden: (_, derivedSettings) =>
PLUGIN_EMBEDDING.isEnabled() && derivedSettings["enable-embedding"],
},
],
// We need to do this because EE plugins would load after this file in unit tests
component: ({ updateSetting }) => (
<PLUGIN_ADMIN_SETTINGS.InteractiveEmbeddingSettings
updateSetting={updateSetting}
/>
),
settings: [],
},
license: {
name: t`License`,

View File

@@ -49,7 +49,7 @@
--mb-color-bg-black: #2e353b;
--mb-color-bg-dark: #93a1ab;
--mb-color-bg-medium: #edf2f5;
--mb-color-bg-light: #f9fbfc;
--mb-color-bg-light: var(--mb-base-color-gray-5);
--mb-color-bg-white: var(--mb-base-color-white);
--mb-color-bg-error: #ed6e6e55;
--mb-color-bg-night: #42484e;
@@ -63,9 +63,11 @@
--mb-color-text-tertiary: var(--mb-color-text-light);
--mb-color-text-selected: var(--mb-base-color-white);
--mb-color-text-hover: var(--mb-base-color-brand-40);
--mb-color-text-disabled: var(--mb-base-color-gray-50);
--mb-color-text-brand: var(--mb-base-color-brand-40);
--mb-color-text-white: var(--mb-base-color-white);
--mb-color-background: var(--mb-base-color-white);
--mb-color-background-info: var(--mb-base-color-gray-5);
--mb-color-background-selected: var(--mb-base-color-brand-40);
--mb-color-background-hover: var(--mb-base-color-brand-10);
--mb-color-background-disabled: var(--mb-base-color-gray-10);
@@ -76,6 +78,8 @@
/**
* Base colors
*
* @see https://www.figma.com/design/YxiDXi1DzmXJCTMCuqXaaY/Applying-the-updated-colors?node-id=4001-16486&node-type=FRAME&t=Iwv4nAfH8QF1J5QG-0
*
* These are the base colors that should never change and never be used
* directly in any components or CSS properties. They should only be used
* to define the other semantic colors in this file.
@@ -116,6 +120,11 @@
var(--mb-color-brand),
white 45%
);
--mb-base-color-brand-20: color-mix(
in srgb,
var(--mb-color-brand),
white 70%
);
--mb-base-color-brand-10: color-mix(
in srgb,
var(--mb-color-brand),

View File

@@ -0,0 +1,38 @@
import styled from "@emotion/styled";
import type { MantineSize } from "@mantine/styles";
type BadgeColor = "brand" | "gray";
const COLOR_VARIANTS = {
brand: {
color: "var(--mb-color-text-white)",
background: "var(--mb-color-brand)",
},
gray: {
color: "var(--mb-color-text-black)",
background: "var(--mb-base-color-gray-20)",
},
};
export const Badge = styled.span<{
color: BadgeColor;
uppercase?: boolean;
px?: MantineSize;
py?: MantineSize;
fz?: MantineSize;
}>`
padding: ${({ px, py, theme }) => {
const paddingY = py ? theme.spacing[py] : "0";
const paddingX = px ? theme.spacing[px] : "4px";
return `${paddingY} ${paddingX}`;
}};
display: inline-block;
line-height: 1rem;
font-size: ${({ fz, theme }) => (fz ? theme.fontSizes[fz] : "0.625rem")};
font-weight: 700;
border-radius: 4px;
text-transform: ${props =>
(props.uppercase ?? true) ? "uppercase" : "none"};
color: ${({ color }) => COLOR_VARIANTS[color].color};
background: ${({ color }) => COLOR_VARIANTS[color].background};
`;

View File

@@ -106,6 +106,7 @@ export const EmbedHomepage = () => {
}
learnMoreInteractiveEmbedUrl={learnMoreInteractiveEmbedding + utmTags}
learnMoreStaticEmbedUrl={learnMoreStaticEmbedding + utmTags}
sdkUrl={"https://metaba.se/sdk" + utmTags}
/>
<FeedbackModal
opened={feedbackModalOpened}

View File

@@ -15,6 +15,7 @@ import {
} from "metabase/ui";
import type { EmbeddingHomepageDismissReason } from "metabase-types/api";
import { Badge } from "./Badge";
import { InteractiveTabContent } from "./InteractiveTabContent";
import { StaticTabContent } from "./StaticTabContent";
import type { EmbeddingHomepageInitialTab } from "./types";
@@ -31,6 +32,7 @@ export type EmbedHomepageViewProps = {
analyticsDocsUrl: string;
learnMoreStaticEmbedUrl: string;
learnMoreInteractiveEmbedUrl: string;
sdkUrl: string;
};
export const EmbedHomepageView = (props: EmbedHomepageViewProps) => {
@@ -39,6 +41,7 @@ export const EmbedHomepageView = (props: EmbedHomepageViewProps) => {
initialTab,
embeddingDocsUrl,
analyticsDocsUrl,
sdkUrl,
onDismiss,
} = props;
return (
@@ -86,6 +89,26 @@ export const EmbedHomepageView = (props: EmbedHomepageViewProps) => {
</Tabs>
</Card>
<Card>
<Text color="text-medium" fw="bold" size="sm">
{/* eslint-disable-next-line no-literal-metabase-strings -- only visible to admins */}
{t`New in Metabase 0.51`}
</Text>
<Group spacing="sm">
<Text color="text-dark" fw="bold">{t`Embedded analytics SDK`}</Text>
<Badge color="brand">{t`PRO & ENTERPRISE`}</Badge>
<Badge color="gray">{t`Beta`}</Badge>
</Group>
<Text color="text-light" size="sm">
{/* eslint-disable-next-line no-literal-metabase-strings -- only visible to admins */}
{t`Interactive embedding with full, granular control. Embed and style individual Metabase components in your app, and tailor the experience to each person. Allows for CSS styling, custom user flows, event subscriptions, and more. Only available with SSO via JWT.`}{" "}
<Anchor size="sm" href={sdkUrl}>
{t`Read more in the docs.`}
</Anchor>
</Text>
</Card>
{embeddingAutoEnabled && (
<Card>
<Text

View File

@@ -23,6 +23,7 @@ import {
type EntityId,
type PermissionSubject,
} from "metabase/admin/permissions/types";
import { InteractiveEmbeddingSettings } from "metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings";
import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors";
import type {
ActualModelFilters,
@@ -95,6 +96,10 @@ export const PLUGIN_ADMIN_TROUBLESHOOTING = {
GET_EXTRA_NAV: (): ReactNode[] => [],
};
export const PLUGIN_ADMIN_SETTINGS = {
InteractiveEmbeddingSettings: InteractiveEmbeddingSettings,
};
// functions that update the sections
export const PLUGIN_ADMIN_SETTINGS_UPDATES: ((
sections: typeof ADMIN_SETTINGS_SECTIONS,
@@ -505,6 +510,10 @@ export const PLUGIN_EMBEDDING = {
isInteractiveEmbeddingEnabled: (_state: State) => false,
};
export const PLUGIN_EMBEDDING_SDK = {
isEnabled: () => false,
};
export const PLUGIN_CONTENT_VERIFICATION = {
VerifiedFilter: {} as SearchFilterComponent<"verified">,
availableModelFilters: {} as AvailableModelFilters,

View File

@@ -7,7 +7,8 @@ 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");
// TODO: Change this to `enable-embedding-static` once the BE is implemented.
const isStaticEmbeddingEnabled = useSetting("enable-embedding");
const isAdmin = useSelector(getUserIsAdmin);
if (!isAdmin) {
@@ -23,9 +24,9 @@ export function EmbedMenuItem({ onClick }: { onClick: () => void }) {
<Icon name="embed" />
</Center>
}
onClick={isEmbeddingEnabled ? onClick : undefined}
onClick={isStaticEmbeddingEnabled ? onClick : undefined}
>
{isEmbeddingEnabled ? (
{isStaticEmbeddingEnabled ? (
<Title order={4}>{t`Embed`}</Title>
) : (
<Link

View File

@@ -28,10 +28,14 @@ export const getInputOverrides = (): MantineThemeOverride["components"] => ({
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"),

View File

@@ -5,18 +5,30 @@
[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.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))
:feature :embedding
:visibility :public
:audit :getter
:encryption :no)
(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 (setting/get-value-of-type :string :embedding-app-origin))
:number-embedded-questions (t2/count :model/Card :enable_embedding true)
:number-embedded-dashboards (t2/count :model/Dashboard :enable_embedding true)}))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Embed Settings ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defsetting enable-embedding
(deferred-tru "Allow admins to securely embed questions and dashboards within other applications?")
@@ -25,18 +37,82 @@
:visibility :authenticated
:export? true
: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)}))))
:deprecated "0.51.0"
:setter (make-embedding-toggle-setter :enable-embedding "embedding"))
(defsetting embedding-app-origin
(deferred-tru "Allow this origin to embed the full Metabase application.")
;; This value is usually gated by [[enable-embedding]]
:feature :embedding
:type :string
:export? false
:visibility :public
:audit :getter
:encryption :no)
(defsetting enable-embedding-sdk
(deferred-tru "Allow admins to embed Metabase via the SDK?")
:type :boolean
:default false
:visibility :authenticated
:export? false
:audit :getter
:setter (make-embedding-toggle-setter :enable-embedding-sdk "sdk-embedding"))
(mu/defn- ignore-localhost :- :string
"Remove localhost:* or localhost:<port> 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
(when (and (premium-features/has-feature? :embedding-sdk)
;; Cannot set the SDK origins if the SDK embedding is disabled. so it will remain localhost:*.
(setting/get-value-of-type :boolean :enable-embedding-sdk))
(->> new-value
ignore-localhost
(setting/set-value-of-type! :string :embedding-app-origins-sdk)))))
(defsetting embedding-app-origins-sdk
(deferred-tru "Allow this origin to embed Metabase SDK")
:type :string
:export? false
:visibility :public
:encryption :no
:audit :getter
: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-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"))
(defsetting enable-embedding-interactive
(deferred-tru "Allow admins to embed Metabase via interactive embedding?")
:feature :embedding
:type :boolean
:default false
:visibility :authenticated
:export? false
:audit :getter
:setter (make-embedding-toggle-setter :enable-embedding-interactive "interactive-embedding"))
;; settings for the embedding homepage
(defsetting embedding-homepage

View File

@@ -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
@@ -113,21 +113,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)
(embed.settings/embedding-app-origin))]
eao
"'none'")))))
(defn parse-url
"Returns an object with protocol, domain and port for the given url"
@@ -170,9 +164,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)
@@ -188,37 +183,28 @@
"Returns headers for CORS requests"
[origin]
(merge
(when
(approved-origin? origin (embedding-app-origin-sdk))
(when (approved-origin? origin (embed.settings/embedding-app-origins-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))
{"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))
(when (embed.settings/enable-embedding-sdk)
(access-control-headers origin))
(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)
(embed.settings/embedding-app-origin))]
(format "ALLOW-FROM %s" (-> eao (str/split #" ") first))
"DENY")})
{;; Tell browser to block suspected XSS attacks
"X-XSS-Protection" "1; mode=block"

View File

@@ -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)))))))

View File

@@ -184,17 +184,22 @@
(deftest test-access-control-headers?
(testing "Should always allow localhost:*"
(tu/with-temporary-setting-values [enable-embedding true
embedding-app-origin nil]
(tu/with-temporary-setting-values [enable-embedding-sdk true
embedding-app-origins-sdk 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 disable CORS when enable-embedding-sdk is disabled"
(tu/with-temporary-setting-values [enable-embedding-sdk false
embedding-app-origins-sdk nil]
(is (= "http://localhost:8080"
(get (mw.security/access-control-headers "http://localhost:8080")
"Access-Control-Allow-Origin"))
"Localhost is always permitted.")
(is (= nil (get (mw.security/access-control-headers "http://1.2.3.4:5555")
"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"]
(mt/with-premium-features #{:embedding-sdk}
(tu/with-temporary-setting-values [enable-embedding-sdk true
embedding-app-origins-sdk "example.com"]
(is (= "https://example.com" (get (mw.security/access-control-headers "https://example.com") "Access-Control-Allow-Origin")))))))