Skip to content
Snippets Groups Projects
Unverified Commit 41b7f329 authored by Phoomparin Mano's avatar Phoomparin Mano Committed by GitHub
Browse files

feat(sdk): generate sample Express.js api and user switcher components via cli (#47060)


* ask for tenancy isolation columns

* deny all permissions for all users group

* create new collections

* add jwt group mappings

* add the permissions step

* add multi-tenancy message in helper text format

* add permission graph

* wire together permissions

* use schema permissions

* use fields from table metadata from query_metadata

* add tenancy field reference

* remove log messages

* deny access to unsandboxed tables

* make permission graph more explicit

* deny access to sample database for customer groups

* add unit test for permission graph

* split permission groups and sandboxes

* jwt settings and hard-coded user attributes

* handle errors when updating sso mappings

* add express api and user switcher

* only fallback to api keys when license is invalid

* add util to sample tenancy column values

* conditional BASE_SSO_API imports

* improve embedding error message

* setup jwt configuration after license step

* setup permissions at the last step

* add missing import

* update steps that requires license

* fix incorrect imports

* add missing useContext

* handle permission update error

* remove tenancyIsolationEnabled field

* add tenancy column sampling

* differentiate tenancy column query error

* rename tenancyColumnValues to tenantIds

* assign sampled tenant ids to user attributes

* add tenant ids

* define collection permissions

* reference sandboxing group by name

* update snippet to be same as the README

* extract ask for tenancy columns to a separate step

* use the customer_id attribute

* query the table query metadata at origin

* append tables correctly

* improve error handling in table scanning

* add retry logic to metadata fetching

* only query metadata for selected fields

* fix race condition with retry

* update loading state and retries

* update comments on jwt license

Co-authored-by: default avatarMahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>

* filter the target table by id

* highlight last selected tenant column

* use breakout to get list of ids

* temporary workaround to reload the whole page

* update row value types

* update row value types

* block non-selected tables

* remove the source-field from sandboxing

* use the fk_target_field_id as instead of target.id

* update unit test

* remove source-field as we only reference our own column

* make native permission types more strict

---------

Co-authored-by: default avatarMahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
Co-authored-by: default avatarOisin Coveney <oisin@metabase.com>
parent c574c09d
Branches
Tags
No related merge requests found
Showing
with 551 additions and 117 deletions
import chalk from "chalk";
import { HARDCODED_USERS } from "../constants/hardcoded-users";
import { CONTAINER_NAME } from "./config";
......@@ -27,6 +29,11 @@ export const INSTANCE_CONFIGURED_MESSAGE = `
export const PREMIUM_TOKEN_REQUIRED_MESSAGE =
" Don't forget to add your premium token to your Metabase instance in the admin settings! The embedding demo will not work without a license.";
export const NO_TENANCY_COLUMN_WARNING_MESSAGE = `
Your have not selected any tables with a multi-tenancy column.
You can still use the SDK, but you will not be able to sandbox your tables.
`;
export const getGeneratedComponentFilesMessage = (path: string) => `
Generated example React components files in "${path}".
You can import the <AnalyticsPage /> component in your React app.
......@@ -43,7 +50,7 @@ export const getMetabaseInstanceSetupCompleteMessage = (instanceUrl: string) =>
// eslint-disable-next-line no-unconditional-metabase-links-render -- link for the CLI message
`
Metabase instance is ready for embedding.
Go to ${instanceUrl} to start using Metabase.
Go to ${chalk.blue(instanceUrl)} to start using Metabase.
You can find your login credentials at METABASE_LOGIN.json
Don't forget to put this file in your .gitignore.
......@@ -62,3 +69,15 @@ export const NOT_ENOUGH_TENANCY_COLUMN_ROWS = `
At least ${HARDCODED_USERS.length} rows with valid tenancy columns are needed for sandboxing.
You can add your tenant's IDs to the "customer_id" user attribute in settings.
`;
export const getExpressServerGeneratedMessage = (filePath: string) => {
const NPM_INSTALL_DEPS_COMMAND = chalk.blue(
"npm install express express-session jsonwebtoken cors node-fetch@2",
);
return `
Generated an example Express.js server in "${filePath}".
Add the dependencies with "${NPM_INSTALL_DEPS_COMMAND}"
Start the server with "node ${filePath}".
`;
};
......@@ -13,6 +13,7 @@ import {
createApiKey,
createModelsAndXrays,
generateCredentials,
generateExpressServerFile,
generateReactComponentFiles,
pickDatabaseTables,
pollMetabaseInstance,
......@@ -42,14 +43,20 @@ export const CLI_STEPS = [
{ id: "addDatabaseConnection", executeStep: addDatabaseConnectionStep },
{ id: "pickDatabaseTables", executeStep: pickDatabaseTables },
{ id: "createModelsAndXrays", executeStep: createModelsAndXrays },
{
id: "generateReactComponentFiles",
executeStep: generateReactComponentFiles,
},
{ id: "setupLicense", executeStep: setupLicense },
// The following steps require the license to be defined first.
{ id: "setupEmbeddingSettings", executeStep: setupEmbeddingSettings },
{ id: "askForTenancyColumns", executeStep: askForTenancyColumns },
{ id: "setupPermissions", executeStep: setupPermissions },
{
id: "generateReactComponentFiles",
executeStep: generateReactComponentFiles,
},
{
id: "generateExpressServerFile",
executeStep: generateExpressServerFile,
},
] as const;
export async function runCli() {
......
......@@ -13,20 +13,25 @@ export const ANALYTICS_CSS_SNIPPET = `
padding: 30px 0;
}
.analytics-header {
.analytics-header,
.analytics-header-left,
.analytics-header-right {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30px 0;
column-gap: 15px;
}
.analytics-header {
justify-content: space-between;
}
.analytics-header-left {
justify-content: flex-start;
}
.analytics-header-right {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 30px 0;
column-gap: 15px;
}
.analytics-header-right > a {
......@@ -46,4 +51,15 @@ export const ANALYTICS_CSS_SNIPPET = `
outline: 1px solid #509EE3;
border-radius: 2px;
}
.analytics-auth-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--mb-color-text-primary);
font-size: 24px;
text-align: center;
}
`.trim();
import { SDK_PACKAGE_NAME } from "../constants/config";
import type { DashboardInfo } from "../types/dashboard";
export const getAnalyticsDashboardSnippet = (
instanceUrl: string,
dashboards: DashboardInfo[],
) => `
import { useState } from 'react'
interface Options {
instanceUrl: string;
dashboards: DashboardInfo[];
userSwitcherEnabled: boolean;
}
export const getAnalyticsDashboardSnippet = (options: Options) => {
const { instanceUrl, dashboards, userSwitcherEnabled } = options;
let imports = `import { ThemeSwitcher } from './theme-switcher'`;
if (userSwitcherEnabled) {
imports += `\nimport { UserSwitcher } from './user-switcher'`;
}
return `
import { useState, useContext } from 'react'
import { InteractiveDashboard } from '${SDK_PACKAGE_NAME}'
import { AnalyticsContext } from "./analytics-provider"
import { ThemeSwitcher } from './theme-switcher'
${imports}
export const AnalyticsDashboard = () => {
const {email} = useContext(AnalyticsContext)
const [dashboardId, setDashboardId] = useState(DASHBOARDS[0].id)
const editLink = \`${instanceUrl}/dashboard/\${dashboardId}\`
......@@ -29,6 +43,8 @@ export const AnalyticsDashboard = () => {
</option>
))}
</select>
${userSwitcherEnabled ? "<UserSwitcher />" : ""}
</div>
<div className="analytics-header-right">
......@@ -41,10 +57,12 @@ export const AnalyticsDashboard = () => {
</div>
</div>
<InteractiveDashboard dashboardId={dashboardId} withTitle withDownloads />
{/** Reload the dashboard when user changes with the key prop */}
<InteractiveDashboard dashboardId={dashboardId} withTitle withDownloads key={email} />
</div>
)
}
const DASHBOARDS = ${JSON.stringify(dashboards, null, 2)}
`;
};
......@@ -3,16 +3,17 @@
* theme switcher, and a sample dashboard.
*/
export const ANALYTICS_PAGE_SNIPPET = `
import { AnalyticsProvider } from './analytics-provider'
import { EmbeddingProvider } from './embedding-provider'
import { AnalyticsDashboard } from './analytics-dashboard'
import { MetabaseEmbedProvider, SampleThemeProvider } from './metabase-provider'
import './analytics.css'
export const AnalyticsPage = () => (
<SampleThemeProvider>
<MetabaseEmbedProvider>
<AnalyticsProvider>
<EmbeddingProvider>
<AnalyticsDashboard />
</MetabaseEmbedProvider>
</SampleThemeProvider>
</EmbeddingProvider>
</AnalyticsProvider>
)
`;
export const ANALYTICS_PROVIDER_SNIPPET_MINIMAL = `
import {createContext, useState} from 'react'
// Used for the example theme switcher component
export const AnalyticsContext = createContext({})
export const AnalyticsProvider = ({children}) => {
const [themeKey, setThemeKey] = useState('light')
return (
<AnalyticsContext.Provider value={{themeKey, setThemeKey}}>
{children}
</AnalyticsContext.Provider>
)
}
`;
export const ANALYTICS_PROVIDER_SNIPPET_WITH_USER_SWITCHER = `
import {createContext, useCallback, useEffect, useState} from 'react'
export const BASE_SSO_API = 'http://localhost:4477'
// Used for the example theme switcher and user switcher components
export const AnalyticsContext = createContext({})
const AUTH_SERVER_DOWN_MESSAGE = \`
Auth server is down.
Please start the server with 'node server.js'
\`
export const AnalyticsProvider = ({children}) => {
const [email, setEmail] = useState(null)
const [authError, setAuthError] = useState(null)
const [themeKey, setThemeKey] = useState('light')
const switchUser = useCallback(async (email) => {
localStorage.setItem('user-email', email)
try {
const res = await fetch(\`\${BASE_SSO_API}/switch-user\`, {
method: 'POST',
body: JSON.stringify({email}),
headers: {'content-type': 'application/json'},
credentials: 'include'
})
if (!res.ok) {
setAuthError(await res.text())
return
}
setEmail(email)
} catch (error) {
const message = error instanceof Error ? error.message : error
if (message.includes('Failed to fetch')) {
setAuthError(AUTH_SERVER_DOWN_MESSAGE)
return
}
setAuthError(message)
}
}, [])
const context = {email, switchUser, authError, themeKey, setThemeKey}
useEffect(() => {
if (!email) {
switchUser(localStorage.getItem('user-email') || 'alice@example.com')
}
}, [email, switchUser])
if (authError) {
return (
<div className="analytics-auth-container">
Login failed. Reason: {authError}
</div>
)
}
if (!email) {
return <div className="analytics-auth-container">Logging In...</div>
}
return (
<AnalyticsContext.Provider value={context}>
{children}
</AnalyticsContext.Provider>
)
}
`;
import { SDK_PACKAGE_NAME } from "../constants/config";
interface Options {
instanceUrl: string;
apiKey: string;
userSwitcherEnabled: boolean;
}
export const getEmbeddingProviderSnippet = (options: Options) => {
const { instanceUrl, apiKey, userSwitcherEnabled } = options;
let imports = "";
let apiKeyOrJwtConfig = "";
// Fallback to API keys when user switching is not enabled.
if (userSwitcherEnabled) {
apiKeyOrJwtConfig += `jwtProviderUri: \`\${BASE_SSO_API}/sso/metabase\`,`;
imports = `import { AnalyticsContext, BASE_SSO_API } from './analytics-provider'`;
} else {
apiKeyOrJwtConfig += `apiKey: '${apiKey}'`;
imports = `import { AnalyticsContext } from './analytics-provider'`;
}
return `
import {useContext, useMemo} from 'react'
import {MetabaseProvider} from '${SDK_PACKAGE_NAME}'
${imports}
/** @type {import('@metabase/embedding-sdk-react').SDKConfig} */
const config = {
metabaseInstanceUrl: \`${instanceUrl}\`,
${apiKeyOrJwtConfig}
}
export const EmbeddingProvider = ({children}) => {
const {themeKey} = useContext(AnalyticsContext)
const theme = useMemo(() => THEMES[themeKey], [themeKey])
return (
<MetabaseProvider config={config} theme={theme}>
{children}
</MetabaseProvider>
)
}
/**
* Sample themes for Metabase components.
*
* @type {Record<string, import('@metabase/embedding-sdk-react').MetabaseTheme>}
*/
const THEMES = {
// Light theme
light: {
colors: {
brand: '#509EE3',
filter: '#7172AD',
'text-primary': '#4C5773',
'text-secondary': '#696E7B',
'text-tertiary': '#949AAB',
border: '#EEECEC',
background: '#F9FBFC',
'background-hover': '#F9FBFC',
positive: '#84BB4C',
negative: '#ED6E6E'
}
},
// Dark theme
dark: {
colors: {
brand: '#509EE3',
filter: '#7172AD',
'text-primary': '#FFFFFF',
'text-secondary': '#FFFFFF',
'text-tertiary': '#FFFFFF',
border: '#5A5F6B',
background: '#2D353A',
'background-hover': '#2D353A',
positive: '#84BB4C',
negative: '#ED6E6E'
}
}
}
`;
};
import { HARDCODED_USERS } from "embedding-sdk/cli/constants/hardcoded-users";
import {
HARDCODED_JWT_SHARED_SECRET,
USER_ATTRIBUTE_CUSTOMER_ID,
} from "../constants/config";
interface Options {
instanceUrl: string;
tenantIds: (number | string)[];
}
const DEFAULT_EXPRESS_SERVER_PORT = 4477;
export const getExpressServerSnippet = (options: Options) => {
const users = HARDCODED_USERS.map((user, i) => ({
...user,
// Assign one of the tenant id in the user's database to their Metabase user attributes.
// This is hard-coded for demonstration purposes.
...(options.tenantIds[i] && {
[USER_ATTRIBUTE_CUSTOMER_ID]: options.tenantIds[i],
}),
}));
return `
const express = require("express");
const fetch = require("node-fetch");
const jwt = require("jsonwebtoken");
const session = require('express-session')
const cors = require('cors')
const PORT = process.env.PORT || ${DEFAULT_EXPRESS_SERVER_PORT}
const METABASE_INSTANCE_URL = '${options.instanceUrl}'
const METABASE_JWT_SHARED_SECRET =
'${HARDCODED_JWT_SHARED_SECRET}'
const USERS = ${JSON.stringify(users, null, 2)}
const getUser = (email) => USERS.find((user) => user.email === email)
async function metabaseAuthHandler(req, res) {
const { user } = req.session;
if (!user) {
return res.status(401).json({
status: "error",
message: "not authenticated",
});
}
const token = jwt.sign(
{
email: user.email,
first_name: user.firstName,
last_name: user.lastName,
groups: user.groups,
${USER_ATTRIBUTE_CUSTOMER_ID}: user.${USER_ATTRIBUTE_CUSTOMER_ID},
exp: Math.round(Date.now() / 1000) + 60 * 10, // 10 minutes expiration
},
// This is the JWT signing secret in your Metabase JWT authentication setting
METABASE_JWT_SHARED_SECRET,
);
const ssoUrl = \`\${METABASE_INSTANCE_URL}/auth/sso?token=true&jwt=\${token}\`;
try {
const response = await fetch(ssoUrl, { method: "GET" });
const token = await response.json();
return res.status(200).json(token);
} catch (error) {
if (error instanceof Error) {
res.status(401).json({
status: "error",
message: "authentication failed",
error: error.message,
});
}
}
}
async function switchUserHandler(req, res) {
const {email} = req.body
const user = getUser(email)
if (!user) {
return res
.status(401)
.json({status: 'error', message: 'unknown user', email})
}
if (req.session.email === email) {
return res.status(200).json({message: 'already logged in', user})
}
req.session.regenerate(() => {
req.session.user = user
res.status(200).json({user})
})
}
const app = express();
// Middleware
// If your FE application is on a different domain from your BE, you need to enable CORS
// by setting Access-Control-Allow-Credentials to true and Access-Control-Allow-Origin
// to your FE application URL.
app.use(
cors({
credentials: true,
origin: true,
}),
);
app.use(
session({
secret: 'session-secret',
resave: false,
saveUninitialized: true,
cookie: { secure: false },
}),
);
app.use(express.json());
// routes
app.get('/', (_, res) => res.send({ok: true}))
app.get("/sso/metabase", metabaseAuthHandler);
app.post('/switch-user', switchUserHandler)
app.listen(PORT, () => {
console.log(\`API running at http://localhost:\${PORT}\`);
});
`;
};
export * from "./analytics-provider-snippet";
export * from "./embedding-provider-snippet";
export * from "./analytics-dashboard-snippet";
export * from "./metabase-provider-snippet";
export * from "./analytics-page-snippet";
export * from "./theme-switcher-snippet";
export * from "./user-switcher-snippet";
export * from "./express-server-snippet";
import { SDK_PACKAGE_NAME } from "embedding-sdk/cli/constants/config";
export const getMetabaseProviderSnippet = (url: string, apiKey: string) => `
import { createContext, useContext, useMemo, useState } from 'react'
import { MetabaseProvider } from '${SDK_PACKAGE_NAME}'
/** @type {import('${SDK_PACKAGE_NAME}').SDKConfig} */
const config = {
metabaseInstanceUrl: \`${url}\`,
apiKey: '${apiKey}'
}
// Used for the example theme switcher component
export const SampleThemeContext = createContext({ themeKey: 'light' })
export const MetabaseEmbedProvider = ({ children }) => {
const { themeKey } = useContext(SampleThemeContext)
const theme = useMemo(() => THEMES[themeKey], [themeKey])
return (
<MetabaseProvider config={config} theme={theme}>
{children}
</MetabaseProvider>
)
}
export const SampleThemeProvider = ({ children }) => {
const [themeKey, setThemeKey] = useState('light')
return (
<SampleThemeContext.Provider value={{themeKey, setThemeKey}}>
{children}
</SampleThemeContext.Provider>
)
}
/**
* Sample themes for Metabase components.
*
* @type {Record<string, import('${SDK_PACKAGE_NAME}').MetabaseTheme>}
*/
const THEMES = {
// Light theme
"light": {
colors: {
brand: "#509EE3",
filter: "#7172AD",
"text-primary": "#4C5773",
"text-secondary": "#696E7B",
"text-tertiary": "#949AAB",
border: "#EEECEC",
background: "#F9FBFC",
"background-hover": "#F9FBFC",
positive: "#84BB4C",
negative: "#ED6E6E"
}
},
// Dark theme
"dark": {
colors: {
brand: "#509EE3",
filter: "#7172AD",
"text-primary": "#FFFFFF",
"text-secondary": "#FFFFFF",
"text-tertiary": "#FFFFFF",
border: "#5A5F6B",
background: "#2D353A",
"background-hover": "#2D353A",
positive: "#84BB4C",
negative: "#ED6E6E"
}
}
}
`;
export const THEME_SWITCHER_SNIPPET = `
import { useContext } from 'react'
import { SampleThemeContext } from './metabase-provider'
import { AnalyticsContext } from './analytics-provider'
export const ThemeSwitcher = () => {
const { themeKey, setThemeKey } = useContext(SampleThemeContext)
const { themeKey, setThemeKey } = useContext(AnalyticsContext)
const ThemeIcon = ICONS[themeKey]
......
import { HARDCODED_USERS } from "../constants/hardcoded-users";
export const getUserSwitcherSnippet = () => {
const users = HARDCODED_USERS.map(user => ({
email: user.email,
firstName: user.firstName,
}));
return `
import {useContext} from 'react'
import {AnalyticsContext} from './analytics-provider'
const USERS = ${JSON.stringify(users, null, 2)}
export const UserSwitcher = () => {
const {email, switchUser} = useContext(AnalyticsContext)
return (
<select
value={email}
onChange={(e) => {
switchUser(e.target.value)
// temporary workaround: reload the page to sign in as the new user
window.location.reload()
}}
className="dashboard-select"
>
{USERS.map((user) => (
<option key={user.email} value={user.email}>
User: {user.firstName}
</option>
))}
</select>
)
}
`;
};
......@@ -9,7 +9,7 @@ import { getComponentSnippets } from "../utils/get-component-snippets";
import { printError, printSuccess } from "../utils/print";
export const generateReactComponentFiles: CliStepMethod = async state => {
const { instanceUrl, apiKey, dashboards = [] } = state;
const { instanceUrl, apiKey, dashboards = [], token } = state;
if (!instanceUrl || !apiKey) {
return [
......@@ -42,6 +42,10 @@ export const generateReactComponentFiles: CliStepMethod = async state => {
instanceUrl,
apiKey,
dashboards,
// Enable user switching only when a valid license is present,
// as JWT requires a valid license.
userSwitcherEnabled: !!token,
});
// Generate sample components files in the specified directory.
......
import fs from "fs/promises";
import { input } from "@inquirer/prompts";
import { dirname } from "path";
import { getExpressServerGeneratedMessage } from "../constants/messages";
import { getExpressServerSnippet } from "../snippets";
import type { CliStepMethod } from "../types/cli";
import { printError } from "../utils/print";
export const generateExpressServerFile: CliStepMethod = async state => {
const { instanceUrl, token } = state;
// If a valid license token is not present, we don't need to generate the Express.js server.
// When JWT is not enabled, they are not able to login with SSO.
if (!token) {
return [{ type: "success" }, state];
}
if (!instanceUrl) {
const message = "Missing instance URL.";
return [{ type: "error", message }, state];
}
let filePath: string;
// eslint-disable-next-line no-constant-condition -- ask until user provides a valid path
while (true) {
filePath = await input({
message: "Where should we save the example Express 'server.js' file?",
default: ".",
validate: value => {
if (!value) {
return "The path cannot be empty.";
}
return true;
},
});
filePath += "/server.js";
// Create the parent directories if it doesn't already exist.
try {
await fs.mkdir(dirname(filePath), { recursive: true });
break;
} catch (error) {
printError(
`The current path is not writeable. Please pick a different path.`,
);
}
}
const snippet = getExpressServerSnippet({
instanceUrl,
tenantIds: state.tenantIds ?? [],
});
await fs.writeFile(filePath, snippet.trim());
console.log(getExpressServerGeneratedMessage(filePath));
return [{ type: "done" }, state];
};
......@@ -13,5 +13,6 @@ export * from "./create-models-and-xrays";
export * from "./ask-tenancy-columns";
export * from "./setup-permission";
export * from "./generate-component-files";
export * from "./generate-express-server-file";
export * from "./setup-license";
export * from "./setup-embedding-settings";
......@@ -30,6 +30,10 @@ export const setupEmbeddingSettings: CliStepMethod = async state => {
"jwt-group-sync": true,
"jwt-user-provisioning-enabled?": true,
"jwt-shared-secret": HARDCODED_JWT_SHARED_SECRET,
// This is a dummy required to activate the JWT feature,
// otherwise the API returns "SSO has not been enabled"
"jwt-identity-provider-uri": state.instanceUrl ?? "",
};
}
......
......@@ -18,7 +18,7 @@ export const setupPermissions: CliStepMethod = async state => {
let res;
const collectionIds: number[] = [];
// Create new collections sequentially
// Create new customer collections sequentially
try {
for (const groupName of SANDBOXED_GROUP_NAMES) {
res = await fetch(`${instanceUrl}/api/collection`, {
......
import {
ANALYTICS_PAGE_SNIPPET,
ANALYTICS_PROVIDER_SNIPPET_MINIMAL,
ANALYTICS_PROVIDER_SNIPPET_WITH_USER_SWITCHER,
THEME_SWITCHER_SNIPPET,
getAnalyticsDashboardSnippet,
getMetabaseProviderSnippet,
getEmbeddingProviderSnippet,
getUserSwitcherSnippet,
} from "../snippets";
import type { DashboardInfo } from "../types/dashboard";
......@@ -10,29 +13,31 @@ interface Options {
instanceUrl: string;
apiKey: string;
dashboards: DashboardInfo[];
userSwitcherEnabled: boolean;
}
export function getComponentSnippets(options: Options) {
const { instanceUrl, apiKey, dashboards } = options;
const { userSwitcherEnabled } = options;
const analyticsDashboardSnippet = getAnalyticsDashboardSnippet(
instanceUrl,
dashboards,
).trim();
const analyticsDashboardSnippet = getAnalyticsDashboardSnippet(options);
const embeddingProviderSnippet = getEmbeddingProviderSnippet(options);
const metabaseProviderSnippet = getMetabaseProviderSnippet(
instanceUrl,
apiKey,
).trim();
const analyticsProviderSnippet = userSwitcherEnabled
? ANALYTICS_PROVIDER_SNIPPET_WITH_USER_SWITCHER
: ANALYTICS_PROVIDER_SNIPPET_MINIMAL;
return [
const components = [
{
name: "metabase-provider",
content: metabaseProviderSnippet,
name: "analytics-provider",
content: analyticsProviderSnippet.trim(),
},
{
name: "embedding-provider",
content: embeddingProviderSnippet.trim(),
},
{
name: "analytics-dashboard",
content: analyticsDashboardSnippet,
content: analyticsDashboardSnippet.trim(),
},
{
name: "theme-switcher",
......@@ -43,4 +48,14 @@ export function getComponentSnippets(options: Options) {
content: ANALYTICS_PAGE_SNIPPET.trim(),
},
];
// Only generate the user switcher when multi-tenancy is enabled.
if (userSwitcherEnabled) {
components.push({
name: "user-switcher",
content: getUserSwitcherSnippet().trim(),
});
}
return components;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment