Compare commits

...

29 Commits

Author SHA1 Message Date
Phoomparin Mano
bea46f52e0 add ad-hoc question and prevent null origin cases 2026-03-13 21:14:55 +07:00
Phoomparin Mano
42bc195e2e add ad hoc question poc 2026-03-13 21:14:55 +07:00
Alberto Perdomo
92d7feaa3c Minor UX improvements 2026-03-13 21:14:55 +07:00
Roman Abdulmanov
6fe18b3833 removed font family 2026-03-13 21:14:55 +07:00
Roman Abdulmanov
1e0e608218 auto save 2026-03-13 21:14:55 +07:00
Roman Abdulmanov
db68574374 resolve cobnflicts 2026-03-13 21:14:55 +07:00
Alberto Perdomo
41b01547c9 UX improvements 2026-03-13 21:14:55 +07:00
Alberto Perdomo
3ac73f01cd Implement preview using current user session 2026-03-13 21:14:55 +07:00
Roman Abdulmanov
7da0f5daa2 get current user 2026-03-13 21:14:55 +07:00
Alberto Perdomo
dd572a7582 Add keycloak for testing 2026-03-13 21:14:55 +07:00
Alberto Perdomo
53298db1c5 Fix saml endpoint logic for cases where the origin header is not supplied. 2026-03-13 21:14:55 +07:00
Alberto Perdomo
a95f0efa4e Handle logo uploads for apps 2026-03-13 21:14:55 +07:00
Roman Abdulmanov
6b6a958cd1 added logo 2026-03-13 21:14:55 +07:00
Alberto Perdomo
d358eddbe4 Migration:
- add logo
- change theme to text
2026-03-13 21:14:55 +07:00
Alberto Perdomo
5b164898e2 Improve UX for apps admin settings 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
d70fc2dd06 app wrapper 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
c9615d516c page size 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
4ec40c7fce fixed theme object 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
e0268e7a7a added theme 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
0ae33f2eac cors 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
1cca634804 oreview 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
feb13408d9 added sso 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
6dbcdb0e1b use collection browser 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
8f1306462f load app 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
c470148d39 clean up 2026-03-13 21:14:54 +07:00
Roman Abdulmanov
ee898240e0 side bar position 2026-03-13 21:14:54 +07:00
Alberto Perdomo
5aa44c8099 Manage apps in admin settings + API 2026-03-13 21:14:08 +07:00
Roman Abdulmanov
1a164ac89e use collection ID vs dashboards 2026-03-13 21:12:36 +07:00
Roman Abdulmanov
675776d273 added route and app generation 2026-03-13 21:12:36 +07:00
32 changed files with 1573 additions and 5 deletions

View File

@@ -88,7 +88,7 @@
(cond
;; Case 1: Embedding SDK header is present - use ACS URL with token and origin
embedding-sdk-header?
(str (acs-url) "?token=" (token-utils/generate-token) "&origin=" (java.net.URLEncoder/encode ^String origin "UTF-8"))
(str (acs-url) "?token=" (token-utils/generate-token) "&origin=" (java.net.URLEncoder/encode ^String (or origin (system/site-url)) "UTF-8"))
;; Case 2: No redirect parameter
(nil? redirect)

View File

@@ -0,0 +1,13 @@
import { createComponent } from "embedding-sdk-package/components/private/ComponentWrapper/ComponentWrapper";
import { getWindow } from "embedding-sdk-shared/lib/get-window";
/**
* A component that renders an ad-hoc question from a base64-encoded MBQL query.
*
* @function
* @category AdHocQuestion
* @param props
*/
export const AdHocQuestion = createComponent(
() => getWindow()?.METABASE_EMBEDDING_SDK_BUNDLE?.AdHocQuestion,
);

View File

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

View File

@@ -11,6 +11,7 @@ EMBEDDING_SDK_CONFIG.isEmbeddingSdk = true;
defineBuildInfo("METABASE_EMBEDDING_SDK_PACKAGE_BUILD_INFO");
defineGlobalDependencies();
export { AdHocQuestion } from "./components/public/AdHocQuestion";
export { CollectionBrowser } from "./components/public/CollectionBrowser";
export { CreateQuestion } from "./components/public/CreateQuestion";
export { CreateDashboardModal } from "./components/public/CreateDashboardModal";
@@ -36,6 +37,7 @@ export {
type CollectionBrowserProps,
type CollectionBrowserListColumns,
} from "embedding-sdk-bundle/components/public/CollectionBrowser";
export { type AdHocQuestionProps } from "embedding-sdk-bundle/components/public/AdHocQuestion";
export { type CreateDashboardModalProps } from "embedding-sdk-bundle/components/public/CreateDashboardModal";
export { type CreateQuestionProps } from "embedding-sdk-bundle/components/public/CreateQuestion";
export type {

View File

@@ -90,8 +90,12 @@ const runFetchRequestToken = async (
const refreshUserJwt = async (url: string) => {
let clientBackendResponse;
try {
// Use window.location.origin as base to support relative URLs like "/api/sso"
const urlWithSource = new URL(url, window.location.origin);
// Use window.location.origin as base to support relative URLs like "/api/sso".
// In sandboxed iframes, window.location.origin is the string "null" (not a valid URL),
// so we must omit the base in that case (the url must then be absolute).
const origin = window.location.origin;
const base = origin !== "null" ? origin : undefined;
const urlWithSource = new URL(url, base);
urlWithSource.searchParams.set("response", "json");
clientBackendResponse = await fetch(urlWithSource.toString(), {
method: "GET",

View File

@@ -0,0 +1,62 @@
import { useMemo } from "react";
import { withPublicComponentWrapper } from "embedding-sdk-bundle/components/private/PublicComponentWrapper";
import {
SdkQuestion,
type SdkQuestionProps,
} from "embedding-sdk-bundle/components/public/SdkQuestion/SdkQuestion";
import { b64_to_utf8 } from "metabase/lib/encoding";
import type { Card } from "metabase-types/api";
/**
* @interface
* @expand
* @category AdHocQuestion
*/
export type AdHocQuestionProps = Omit<
SdkQuestionProps,
| "questionId"
| "token"
| "deserializedCard"
| "getClickActionMode"
| "navigateToNewCard"
| "backToDashboard"
> & {
/**
* Base64-encoded MBQL query to run as an ad-hoc question.
*
* The query should be a JSON object encoded in base64. For example:
* ```
* btoa(JSON.stringify({
* "lib/type": "mbql/query",
* "database": 2,
* "stages": [{ "lib/type": "mbql.stage/mbql", "source-table": 73 }]
* }))
* ```
*/
query: string;
};
const AdHocQuestionInner = ({ query, ...props }: AdHocQuestionProps) => {
const deserializedCard = useMemo((): Card => {
const decodedQuery = JSON.parse(b64_to_utf8(query));
return {
display: "table",
dataset_query: decodedQuery,
visualization_settings: {},
} as Card;
}, [query]);
return (
<SdkQuestion
questionId={null}
deserializedCard={deserializedCard}
{...props}
/>
);
};
export const AdHocQuestion = withPublicComponentWrapper(AdHocQuestionInner, {
supportsGuestEmbed: false,
});

View File

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

View File

@@ -19,6 +19,7 @@ import "sdk-specific-imports";
import type { MetabaseEmbeddingSdkBundleExports } from "./types/sdk-bundle";
import { AdHocQuestion } from "./components/public/AdHocQuestion";
import { CollectionBrowser } from "./components/public/CollectionBrowser";
import { CreateDashboardModal } from "./components/public/CreateDashboardModal";
import { CreateQuestion } from "./components/public/CreateQuestion";
@@ -53,6 +54,7 @@ defineBuildInfo("METABASE_EMBEDDING_SDK_BUNDLE_BUILD_INFO");
* and should be done via the deprecation of the field first.
*/
const sdkBundleExports: MetabaseEmbeddingSdkBundleExports = {
AdHocQuestion,
CollectionBrowser,
CreateDashboardModal,
CreateQuestion,

View File

@@ -1,5 +1,6 @@
import type { JSXElementConstructor } from "react";
import type { AdHocQuestion } from "embedding-sdk-bundle/components/public/AdHocQuestion/AdHocQuestion";
import type { CollectionBrowser } from "embedding-sdk-bundle/components/public/CollectionBrowser";
import type { ComponentProvider } from "embedding-sdk-bundle/components/public/ComponentProvider";
import type { CreateDashboardModal } from "embedding-sdk-bundle/components/public/CreateDashboardModal";
@@ -42,6 +43,7 @@ export type MetabaseEmbeddingSdkBundleExports = PublicExports &
SchemaValidationUtils;
export type PublicExports = {
AdHocQuestion: InternalComponent<typeof AdHocQuestion>;
CollectionBrowser: InternalComponent<typeof CollectionBrowser>;
CreateDashboardModal: InternalComponent<typeof CreateDashboardModal>;
CreateQuestion: InternalComponent<typeof CreateQuestion>;

View File

@@ -1,3 +1,28 @@
export type AppId = number;
export type App = {
id: AppId;
name: string;
auth_method: "jwt" | "saml";
theme: string | null;
logo: string | null;
published: boolean;
collection_id: number;
created_at: string;
updated_at: string;
};
export type CreateAppRequest = {
name: string;
auth_method: "jwt" | "saml";
collection_id: number;
theme?: string | null;
logo?: string | null;
published?: boolean;
};
export type UpdateAppRequest = Partial<CreateAppRequest> & { id: AppId };
export type ApiKeyId = number;
export type ApiKey = {

View File

@@ -18,7 +18,8 @@ export type AdminPathKey =
| "performance"
| "performance-models"
| "performance-dashboards-and-questions"
| "performance-databases";
| "performance-databases"
| "apps";
export type AdminPath = {
key: AdminPathKey;

View File

@@ -24,6 +24,11 @@ export const getAdminPaths: () => AdminPath[] = () => {
path: "/admin/embedding",
key: "embedding",
},
{
name: t`Apps`,
path: "/admin/apps",
key: "apps",
},
{
name: t`AI`,
path: "/admin/metabot",

View File

@@ -0,0 +1,603 @@
import { useDebouncedCallback, useDisclosure } from "@mantine/hooks";
import { useFormik } from "formik";
import type { ChangeEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { t } from "ttag";
import {
useCreateAppMutation,
useDeleteAppMutation,
useGetAppQuery,
useGetCollectionQuery,
useUpdateAppMutation,
} from "metabase/api";
import { ConfirmModal } from "metabase/common/components/ConfirmModal";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { CollectionPickerModal } from "metabase/common/components/Pickers";
import { useSetting } from "metabase/common/hooks";
import { useToast } from "metabase/common/hooks/use-toast";
import { useRouter } from "metabase/router";
import {
Alert,
Anchor,
Box,
Button,
Collapse,
Divider,
Flex,
Group,
Input,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
} from "metabase/ui";
const MB = 1024 * 1024;
const IMAGE_SIZE_LIMIT = 2 * MB;
/** Theme color configuration keys */
const THEME_COLOR_KEYS = [
"brand",
"text-primary",
"text-secondary",
"background",
"background-hover",
"border",
"positive",
"negative",
] as const;
function getColorLabel(key: string): string {
switch (key) {
case "brand":
return t`Brand`;
case "text-primary":
return t`Text Primary`;
case "text-secondary":
return t`Text Secondary`;
case "background":
return t`Background`;
case "background-hover":
return t`Background Hover`;
case "border":
return t`Border`;
case "positive":
return t`Positive`;
case "negative":
return t`Negative`;
default:
return key;
}
}
interface ThemeConfig {
preset?: "light" | "dark";
colors?: Record<string, string>;
}
interface AppFormValues {
name: string;
auth_method: "jwt" | "saml";
collection_id: number;
theme: string;
logo: string | null;
}
function useAuthMethodOptions() {
const jwtEnabled = useSetting("jwt-enabled");
const samlEnabled = useSetting("saml-enabled");
const data = [
{ value: "jwt", label: "JWT", disabled: !jwtEnabled },
{ value: "saml", label: "SAML", disabled: !samlEnabled },
];
const noneEnabled = !jwtEnabled && !samlEnabled;
return { data, noneEnabled };
}
export function AppForm() {
const { params, router } = useRouter();
const idParam = params?.id as string | undefined;
const id = idParam ? parseInt(idParam, 10) : undefined;
const isEditing = id !== undefined;
const [createApp] = useCreateAppMutation();
const [updateApp] = useUpdateAppMutation();
const [deleteApp] = useDeleteAppMutation();
const [sendToast] = useToast();
const {
data: existingApp,
isLoading,
error,
} = useGetAppQuery(id!, {
skip: !isEditing,
});
const formik = useFormik<AppFormValues>({
initialValues: {
name: "",
auth_method: "jwt",
collection_id: 0,
theme: "",
logo: null,
},
validate: (values) => {
const errors: Partial<Record<keyof AppFormValues, string>> = {};
if (!values.name.trim()) {
errors.name = t`Name is required`;
}
if (!values.collection_id) {
errors.collection_id = t`Collection is required`;
}
return errors;
},
onSubmit: async (values) => {
const payload = {
name: values.name,
auth_method: values.auth_method,
collection_id: values.collection_id,
theme: values.theme || null,
logo: values.logo || null,
};
if (isEditing) {
await updateApp({ id: id!, ...payload });
} else {
await createApp(payload);
}
router.push("/admin/apps");
},
});
const initialLoadDone = useRef(false);
useEffect(() => {
if (existingApp) {
formik.resetForm({
values: {
name: existingApp.name,
auth_method: existingApp.auth_method,
collection_id: existingApp.collection_id,
theme: existingApp.theme ?? "",
logo: existingApp.logo ?? null,
},
});
initialLoadDone.current = true;
}
// We only want to run this once when the app loads
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingApp]);
// Auto-save with debounce
const debouncedSave = useDebouncedCallback(async (values: AppFormValues) => {
const payload = {
name: values.name,
auth_method: values.auth_method,
collection_id: values.collection_id,
theme: values.theme || null,
logo: values.logo || null,
};
if (isEditing && id) {
await updateApp({ id, ...payload });
sendToast({ message: t`Saved` });
}
}, 1000);
useEffect(() => {
// Only auto-save after initial load and when form is dirty
if (
isEditing &&
initialLoadDone.current &&
formik.dirty &&
formik.isValid
) {
debouncedSave(formik.values);
}
}, [formik.values, formik.dirty, formik.isValid, isEditing, debouncedSave]);
const handleDelete = useCallback(async () => {
await deleteApp(id!);
router.push("/admin/apps");
}, [deleteApp, id, router]);
if (isEditing) {
return (
<LoadingAndErrorWrapper loading={isLoading} error={error}>
<AppFormContent
formik={formik}
isEditing={isEditing}
appName={existingApp?.name ?? ""}
onDelete={handleDelete}
/>
</LoadingAndErrorWrapper>
);
}
return (
<AppFormContent
formik={formik}
isEditing={isEditing}
appName=""
onDelete={null}
/>
);
}
function AppFormContent({
formik,
isEditing,
appName,
onDelete,
}: {
formik: ReturnType<typeof useFormik<AppFormValues>>;
isEditing: boolean;
appName: string;
onDelete: (() => Promise<void>) | null;
}) {
const [pickerOpened, { open: openPicker, close: closePicker }] =
useDisclosure(false);
const [
deleteModalOpened,
{ open: openDeleteModal, close: closeDeleteModal },
] = useDisclosure(false);
const collectionId = formik.values.collection_id;
const { data: collection } = useGetCollectionQuery(
{ id: collectionId },
{ skip: !collectionId },
);
const { data: authMethodData, noneEnabled: noAuthEnabled } =
useAuthMethodOptions();
return (
<Stack p="lg" maw="40rem" gap="lg">
<Group justify="space-between" align="center">
<Title order={2}>{isEditing ? t`Edit App` : t`Create App`}</Title>
{isEditing && appName && (
<Button
component="a"
href={`/apps/${encodeURIComponent(appName)}?useCurrentSession=true`}
target="_blank"
variant="outline"
>
{t`Preview`}
</Button>
)}
</Group>
<form onSubmit={formik.handleSubmit}>
<Stack gap="md">
<TextInput
label={t`Name`}
required
{...formik.getFieldProps("name")}
/>
<Select
label={t`Auth Method`}
required
data={authMethodData}
value={formik.values.auth_method}
onChange={(value) =>
formik.setFieldValue("auth_method", value ?? "jwt")
}
/>
{noAuthEnabled && (
<Alert variant="light" color="warning">
{t`No authentication method is configured.`}{" "}
<Anchor href="/admin/settings/authentication">
{t`Configure JWT or SAML in authentication settings.`}
</Anchor>
</Alert>
)}
<Stack gap={4}>
<Input.Label required>{t`Collection`}</Input.Label>
{collectionId ? (
<Group gap="xs">
<Text>{collection?.name ?? "…"}</Text>
<Anchor onClick={openPicker}>{t`Change collection`}</Anchor>
</Group>
) : (
<Anchor onClick={openPicker}>{t`Select a collection`}</Anchor>
)}
{formik.touched.collection_id && formik.errors.collection_id && (
<Text size="xs" c="error">
{formik.errors.collection_id}
</Text>
)}
</Stack>
{pickerOpened && (
<CollectionPickerModal
value={
collectionId
? { id: collectionId, model: "collection" }
: undefined
}
onChange={(item) => {
formik.setFieldValue("collection_id", item.id);
closePicker();
}}
onClose={closePicker}
/>
)}
<ThemeEditor
value={formik.values.theme}
onChange={(theme) => formik.setFieldValue("theme", theme)}
/>
<LogoUploadField
value={formik.values.logo}
onChange={(logo) => formik.setFieldValue("logo", logo)}
/>
<Divider mt="md" />
<Group justify="flex-end">
{onDelete ? (
<Button color="error" variant="subtle" onClick={openDeleteModal}>
{t`Delete app`}
</Button>
) : (
<Button type="submit" variant="filled">
{t`Create`}
</Button>
)}
</Group>
{onDelete && (
<ConfirmModal
opened={deleteModalOpened}
title={t`Delete this app?`}
message={t`This will permanently delete the app and cannot be undone.`}
confirmButtonText={t`Delete`}
confirmButtonProps={{ color: "error" }}
onConfirm={onDelete}
onClose={closeDeleteModal}
/>
)}
</Stack>
</form>
</Stack>
);
}
function LogoUploadField({
value,
onChange,
}: {
value: string | null;
onChange: (logo: string | null) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [fileName, setFileName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
function handleFileUpload(event: ChangeEvent<HTMLInputElement>) {
setErrorMessage("");
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.size > IMAGE_SIZE_LIMIT) {
setErrorMessage(
t`The image you chose is larger than 2MB. Please choose another one.`,
);
return;
}
const reader = new FileReader();
reader.onload = async (readerEvent) => {
const dataUri = readerEvent.target?.result as string;
if (!(await isImageIntact(dataUri))) {
setErrorMessage(
t`The image you chose is corrupted. Please choose another one.`,
);
return;
}
setFileName(file.name);
onChange(dataUri);
};
reader.readAsDataURL(file);
}
function handleRemove() {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setFileName("");
setErrorMessage("");
onChange(null);
}
return (
<Stack gap={4}>
<Input.Label>{t`Logo`}</Input.Label>
{errorMessage && (
<Text size="xs" c="error">
{errorMessage}
</Text>
)}
<Paper withBorder shadow="none">
<Flex>
<Flex
align="center"
justify="center"
w="7.5rem"
style={{ borderRight: "1px solid var(--mb-color-border)" }}
>
{value && (
<img
src={value}
alt={t`Logo preview`}
style={{
maxWidth: "6rem",
maxHeight: "4rem",
objectFit: "contain",
}}
/>
)}
</Flex>
<Flex p="md" gap="sm" align="center" style={{ flex: 1 }}>
<Button
size="compact-md"
variant="default"
onClick={() => fileInputRef.current?.click()}
>
{t`Choose file`}
</Button>
<input
ref={fileInputRef}
hidden
type="file"
accept="image/jpeg,image/png,image/svg+xml"
multiple={false}
onChange={handleFileUpload}
/>
<Text size="sm" c="text-medium" truncate style={{ flex: 1 }}>
{fileName || (value ? t`Logo uploaded` : t`No file chosen`)}
</Text>
{value && (
<Anchor size="sm" onClick={handleRemove}>
{t`Remove`}
</Anchor>
)}
</Flex>
</Flex>
</Paper>
</Stack>
);
}
async function isImageIntact(dataUri: string): Promise<boolean> {
return new Promise((resolve) => {
const img = document.createElement("img");
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = dataUri;
});
}
function ThemeEditor({
value,
onChange,
}: {
value: string;
onChange: (theme: string) => void;
}) {
const [colorsOpened, { toggle: toggleColors }] = useDisclosure(false);
// Parse current theme value
const themeConfig = useMemo<ThemeConfig>(() => {
if (!value) {
return {};
}
try {
return JSON.parse(value) as ThemeConfig;
} catch {
return {};
}
}, [value]);
const updateTheme = useCallback(
(updates: Partial<ThemeConfig>) => {
const newConfig = { ...themeConfig, ...updates };
// Clean up empty values
if (!newConfig.preset) {
delete newConfig.preset;
}
if (newConfig.colors && Object.keys(newConfig.colors).length === 0) {
delete newConfig.colors;
}
if (Object.keys(newConfig).length === 0) {
onChange("");
} else {
onChange(JSON.stringify(newConfig, null, 2));
}
},
[themeConfig, onChange],
);
const updateColor = useCallback(
(key: string, colorValue: string) => {
const newColors = { ...themeConfig.colors };
if (colorValue) {
newColors[key] = colorValue;
} else {
delete newColors[key];
}
updateTheme({ colors: newColors });
},
[themeConfig.colors, updateTheme],
);
const hasCustomColors =
themeConfig.colors && Object.keys(themeConfig.colors).length > 0;
return (
<Stack gap="sm">
<Input.Label>{t`Theme`}</Input.Label>
<Select
label={t`Preset`}
placeholder={t`Select preset`}
clearable
data={[
{ value: "light", label: t`Light` },
{ value: "dark", label: t`Dark` },
]}
value={themeConfig.preset ?? null}
onChange={(preset) =>
updateTheme({ preset: (preset as "light" | "dark") || undefined })
}
/>
<Box>
<Anchor size="sm" onClick={toggleColors}>
{colorsOpened ? t`Hide color settings` : t`Customize colors`}
{hasCustomColors && ` (${Object.keys(themeConfig.colors!).length})`}
</Anchor>
</Box>
<Collapse in={colorsOpened}>
<Paper withBorder p="md">
<Stack gap="sm">
{THEME_COLOR_KEYS.map((key) => (
<Group key={key} gap="xs" align="flex-end">
<TextInput
label={getColorLabel(key)}
placeholder="#509EE3"
style={{ flex: 1 }}
value={themeConfig.colors?.[key] ?? ""}
onChange={(e) => updateColor(key, e.target.value)}
/>
<input
type="color"
value={themeConfig.colors?.[key] || "#509EE3"}
onChange={(e) => updateColor(key, e.target.value)}
style={{
width: 36,
height: 36,
padding: 0,
border: "1px solid var(--mb-color-border)",
borderRadius: 4,
cursor: "pointer",
}}
/>
</Group>
))}
</Stack>
</Paper>
</Collapse>
{value && (
<Paper withBorder p="xs" bg="bg-light">
<Text size="xs" c="text-medium" style={{ fontFamily: "monospace" }}>
{value}
</Text>
</Paper>
)}
</Stack>
);
}

View File

@@ -0,0 +1,103 @@
import { useCallback } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { useGetCollectionQuery, useListAppsQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import AdminS from "metabase/css/admin.module.css";
import { useRouter } from "metabase/router";
import { Badge, Button, Group, Stack, Text, Title } from "metabase/ui";
import type { App } from "metabase-types/api/admin";
export function AppsList() {
const { data: apps, isLoading, error } = useListAppsQuery();
const { router } = useRouter();
const handleCreate = useCallback(() => {
router.push("/admin/apps/create");
}, [router]);
const handleConfigure = useCallback(
(id: number) => {
router.push(`/admin/apps/${id}`);
},
[router],
);
return (
<Stack p="lg" gap="lg">
<Group justify="space-between">
<Title order={2}>{t`Apps`}</Title>
<Button variant="filled" onClick={handleCreate}>
{t`New app`}
</Button>
</Group>
<LoadingAndErrorWrapper loading={isLoading} error={error}>
{apps && apps.length === 0 ? (
<Text c="text-light">{t`No apps have been created yet.`}</Text>
) : (
<table className={AdminS.ContentTable}>
<thead>
<tr>
<th>{t`Name`}</th>
<th>{t`Auth Method`}</th>
<th>{t`Published`}</th>
<th>{t`Collection`}</th>
<th />
</tr>
</thead>
<tbody>
{apps?.map((app) => (
<AppRow key={app.id} app={app} onConfigure={handleConfigure} />
))}
</tbody>
</table>
)}
</LoadingAndErrorWrapper>
</Stack>
);
}
function AppRow({
app,
onConfigure,
}: {
app: App;
onConfigure: (id: number) => void;
}) {
const { data: collection } = useGetCollectionQuery(
{ id: app.collection_id },
{ skip: !app.collection_id },
);
return (
<tr>
<td>{app.name}</td>
<td>
<Badge>{app.auth_method.toUpperCase()}</Badge>
</td>
<td>{app.published === true ? t`Published` : t`Draft`}</td>
<td>{collection?.name ?? app.collection_id}</td>
<td>
<Group gap="xs" justify="flex-end">
<Button
size="xs"
variant="subtle"
onClick={() => onConfigure(app.id)}
>
{t`Configure`}
</Button>
<Button
variant="subtle"
p={0}
component={Link}
to={`/apps/${app.name}`}
target="_blank"
>
{t`Open`}
</Button>
</Group>
</td>
</tr>
);
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { AdminSettingsLayout } from "metabase/common/components/AdminLayout/AdminSettingsLayout";
interface AdminAppsAppProps {
children?: ReactNode;
}
export function AdminAppsApp({ children }: AdminAppsAppProps) {
return <AdminSettingsLayout>{children}</AdminSettingsLayout>;
}

View File

@@ -8,6 +8,9 @@ import {
} from "react-router";
import AdminApp from "metabase/admin/app/components/AdminApp";
import { AppForm } from "metabase/admin/apps/components/AppForm";
import { AppsList } from "metabase/admin/apps/components/AppsList";
import { AdminAppsApp } from "metabase/admin/apps/containers/AdminAppsApp";
import { DatabaseEditApp } from "metabase/admin/databases/containers/DatabaseEditApp";
import { DatabaseListApp } from "metabase/admin/databases/containers/DatabaseListApp";
import { DatabasePage } from "metabase/admin/databases/containers/DatabasePage";
@@ -238,6 +241,15 @@ export const getRoutes = (
to="/admin/embedding"
/>
{/* APPS */}
<Route path="apps" component={createAdminRouteGuard("apps")}>
<Route title={t`Apps`} component={AdminAppsApp}>
<IndexRoute component={AppsList} />
<Route path="create" component={AppForm} />
<Route path=":id" component={AppForm} />
</Route>
</Route>
{/* SETTINGS */}
<Route path="settings" component={createAdminRouteGuard("settings")}>
{getSettingsRoutes()}

View File

@@ -0,0 +1,55 @@
import type {
App,
AppId,
CreateAppRequest,
UpdateAppRequest,
} from "metabase-types/api/admin";
import { Api } from "./api";
import { idTag, invalidateTags, listTag } from "./tags";
export const appApi = Api.injectEndpoints({
endpoints: (builder) => ({
listApps: builder.query<App[], void>({
query: () => `/api/app`,
providesTags: (apps = []) => [
listTag("app"),
...apps.map((app) => idTag("app", app.id)),
],
}),
getApp: builder.query<App, AppId>({
query: (id) => `/api/app/${id}`,
providesTags: (_, __, id) => [idTag("app", id)],
}),
createApp: builder.mutation<App, CreateAppRequest>({
query: (body) => ({
method: "POST",
url: `/api/app`,
body,
}),
invalidatesTags: (_, error) => invalidateTags(error, [listTag("app")]),
}),
updateApp: builder.mutation<App, UpdateAppRequest>({
query: ({ id, ...body }) => ({
method: "PUT",
url: `/api/app/${id}`,
body,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [listTag("app"), idTag("app", id)]),
}),
deleteApp: builder.mutation<void, AppId>({
query: (id) => ({ method: "DELETE", url: `/api/app/${id}` }),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [listTag("app"), idTag("app", id)]),
}),
}),
});
export const {
useListAppsQuery,
useGetAppQuery,
useCreateAppMutation,
useUpdateAppMutation,
useDeleteAppMutation,
} = appApi;

View File

@@ -2,6 +2,7 @@ export * from "./action";
export * from "./activity";
export * from "./api";
export * from "./api-key";
export * from "./app";
export * from "./automagic-dashboards";
export * from "./bookmark";
export * from "./bug-report";

View File

@@ -4,6 +4,7 @@ export const TAG_TYPES = [
"action",
"alert",
"api-key",
"app",
"bookmark",
"cache-config",
"card",

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useMemo } from "react";
import { type ReactNode, useEffect, useMemo, useRef } from "react";
import { P, match } from "ts-pattern";
import { PublicComponentStylesWrapper } from "embedding-sdk-bundle/components/private/PublicComponentStylesWrapper";
@@ -25,6 +25,7 @@ import { createTracker } from "metabase/lib/analytics-untyped";
import { useSelector } from "metabase/lib/redux";
import { PLUGIN_EMBEDDING_IFRAME_SDK } from "metabase/plugins";
import { getSetting } from "metabase/selectors/settings";
import { getUser } from "metabase/selectors/user";
import { Stack } from "metabase/ui";
import { useParamRerenderKey } from "../hooks/use-param-rerender-key";
@@ -126,6 +127,26 @@ const SdkIframeEmbedView = ({
}): ReactNode => {
const rerenderKey = useParamRerenderKey(settings);
const loginStatus = useSdkSelector(getLoginStatus);
const user = useSelector(getUser);
const userInfoSentRef = useRef(false);
// Send user info to parent window when logged in
useEffect(() => {
if (loginStatus?.status === "success" && user && !userInfoSentRef.current) {
userInfoSentRef.current = true;
window.parent.postMessage(
{
type: "metabase.embed.userInfo",
data: {
id: user.id,
email: user.email,
commonName: user.common_name,
},
},
"*",
);
}
}, [loginStatus?.status, user]);
if (loginStatus?.status === "error") {
return (

View File

@@ -1,4 +1,5 @@
import { MetabaseError, SSO_NOT_ALLOWED } from "embedding-sdk-bundle/errors";
import { applyThemePreset } from "embedding-sdk-shared/lib/apply-theme-preset";
import { PLUGIN_EMBED_JS_EE } from "metabase/embedding/embedding-iframe-sdk/plugin";
import type {
EmbedAuthManager,
@@ -31,6 +32,9 @@ const EMBEDDING_ROUTE = "embed/sdk/v1";
/** list of active embeds, used to know which embeds to update when the global config changes */
const _activeEmbeds: Set<MetabaseEmbedElement> = new Set();
/** list of config change callbacks (used by app-wrapper) */
const _configChangeCallbacks: Set<() => void> = new Set();
/** counter used as a parameter in the iframe src to force parallel loading */
let _iframeCounter = 0;
@@ -90,6 +94,11 @@ export const updateAllEmbeds = (
_activeEmbeds.forEach((embedElement) => {
embedElement._updateSettings(config);
});
// Notify config change callbacks (used by app-wrapper)
_configChangeCallbacks.forEach((callback) => {
callback();
});
};
const registerEmbed = (embed: MetabaseEmbedElement) => {
@@ -566,6 +575,134 @@ const MetabaseMetabotElement = createCustomElement("metabase-metabot", [
"target-collection",
]);
/** User info received from the SDK */
type UserInfo = {
id: number;
email: string;
commonName: string;
};
/** List of user info change callbacks (used by app-wrapper) */
const _userInfoCallbacks: Set<(userInfo: UserInfo | null) => void> = new Set();
let _currentUserInfo: UserInfo | null = null;
// Listen for user info messages from iframe
if (typeof window !== "undefined") {
window.addEventListener("message", (event) => {
if (event.data?.type === "metabase.embed.userInfo") {
_currentUserInfo = event.data.data;
_userInfoCallbacks.forEach((callback) => {
callback(_currentUserInfo);
});
}
});
}
/**
* AppWrapper component that applies theme CSS variables to nested content.
* This allows regular HTML elements (like headers) to use theme colors.
*/
class AppWrapperElement extends HTMLElement {
private _styleElement: HTMLStyleElement | null = null;
private _boundApplyTheme: () => void;
private _boundUpdateUser: (userInfo: UserInfo | null) => void;
constructor() {
super();
this._boundApplyTheme = this._applyTheme.bind(this);
this._boundUpdateUser = this._updateUser.bind(this);
}
connectedCallback() {
this.style.display = "block";
this.style.height = "100%";
this.style.width = "100%";
// Register for config change notifications
_configChangeCallbacks.add(this._boundApplyTheme);
this._applyTheme();
// Register for user info notifications
_userInfoCallbacks.add(this._boundUpdateUser);
// Apply current user info if already available
if (_currentUserInfo) {
this._updateUser(_currentUserInfo);
}
}
disconnectedCallback() {
_configChangeCallbacks.delete(this._boundApplyTheme);
_userInfoCallbacks.delete(this._boundUpdateUser);
if (this._styleElement) {
this._styleElement.remove();
this._styleElement = null;
}
}
private _updateUser(userInfo: UserInfo | null) {
const userElement = this.querySelector(".app-user");
if (userElement && userInfo) {
userElement.textContent = userInfo.commonName;
}
}
private _applyTheme() {
const config = (window as any).metabaseConfig || {};
const theme = config.theme;
if (!theme) {
return;
}
// Apply theme preset to resolve colors (preset colors + user colors)
const resolvedTheme = applyThemePreset(theme);
const colors: Record<string, string> =
(resolvedTheme?.colors as Record<string, string>) || {};
const cssVars: string[] = [];
// Extract colors from theme and create CSS variables
for (const [key, value] of Object.entries(colors)) {
if (typeof value === "string") {
cssVars.push(`--mb-color-${key}: ${value};`);
}
}
// Apply font family if specified
if (theme.fontFamily) {
cssVars.push(`--mb-font-family: ${theme.fontFamily};`);
this.style.fontFamily = theme.fontFamily;
}
// Create or update style element with CSS variables
if (cssVars.length > 0) {
if (!this._styleElement) {
this._styleElement = document.createElement("style");
this.prepend(this._styleElement);
}
this._styleElement.textContent = `
app-wrapper {
${cssVars.join("\n ")}
}
`;
}
// Apply background color directly to the element
if (colors.background) {
this.style.backgroundColor = colors.background;
}
// Apply text color directly to the element
if (colors["text-primary"]) {
this.style.color = colors["text-primary"];
}
}
}
if (typeof window !== "undefined" && !customElements.get("app-wrapper")) {
customElements.define("app-wrapper", AppWrapperElement);
}
// Expose the old API that's still used in the tests, we'll probably remove this api unless customers prefer it
if (typeof window !== "undefined") {
(window as any)["metabase.embed"] = {
@@ -578,4 +715,5 @@ export {
MetabaseQuestionElement,
MetabaseManageContentElement,
MetabaseMetabotElement,
AppWrapperElement,
};

75
keycloak/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Keycloak SAML Test Setup
Pre-configured Keycloak instance for testing Metabase SAML authentication.
## Quick Start
```bash
cd keycloak
docker-compose up -d
```
Access Keycloak at `http://localhost:8090`
## Pre-configured Realm: `metabase`
### Admin Console
- **URL**: `http://localhost:8090/admin`
- **Username**: `admin`
- **Password**: `admin`
### Test Users
| Username | Email | Password | Name |
|----------|-------|----------|------|
| `admin` | `admin@metabase.test` | `admin123` | Admin User |
| `testuser` | `testuser@metabase.test` | `test123` | Test User |
### SAML Client: `Metabase`
Pre-configured with:
- **Protocol**: SAML 2.0
- **Client ID / Entity ID**: `Metabase`
- **Name ID Format**: Email
- **Signature Algorithm**: RSA_SHA256
- **Redirect URIs**: `http://localhost:3000/auth/sso*`, `http://localhost:8080/auth/sso*`, `http://metabase.localhost:3000/auth/sso*`
**Attribute Mappings**:
- Email → `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`
- First Name → `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname`
- Last Name → `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname`
## Configure Metabase
1. **Download SAML Certificate**:
- Go to `http://localhost:8090/admin` and login with `admin` / `admin`
- Select the **metabase** realm from the dropdown (top-left)
- Navigate to **Realm settings** → **Keys** tab
- Find the RSA key with algorithm "RS256"
- Click the **Certificate** button and copy the certificate text
2. **Configure in Metabase** (Admin → Settings → Authentication → SAML):
- **SAML Identity Provider URL**: `http://localhost:8090/realms/metabase/protocol/saml`
- **SAML Identity Provider Certificate**: Paste the certificate from step 1
- **SAML Identity Provider Issuer**: `http://localhost:8090/realms/metabase`
- **SAML Application Name**: `Metabase` (must match the client ID)
- Enable SAML and save
3. **Test**: Navigate to Metabase login and click "Sign in with SSO" to try SAML authentication with test users
## Important Notes
- **Certificate must be refreshed** after each `docker-compose down && up` since Keycloak generates a new one
- If you get "Invalid Request" errors, ensure your Metabase site-url matches one of the configured redirect URIs
- Current redirect URIs support: `localhost:3000`, `localhost:8080`, and `metabase.localhost:3000`
## SAML Endpoints
- **Metadata**: `http://localhost:8090/realms/metabase/protocol/saml/descriptor`
- **SSO URL**: `http://localhost:8090/realms/metabase/protocol/saml`
## Troubleshooting
- **"Invalid redirect uri"**: Your Metabase site-url doesn't match the configured redirect URIs. Add your URL to `redirectUris` in `metabase-realm.json`
- **"Invalid Request"**: Entity ID mismatch. Ensure SAML Application Name in Metabase is set to `Metabase`
- **"Cannot_match_source_hash"**: Keycloak can't find the client. Check that the client ID is `Metabase` in the realm config

View File

@@ -0,0 +1,23 @@
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: metabase-keycloak
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HTTP_PORT: 8080
ports:
- "8090:8080"
volumes:
- .:/opt/keycloak/data/import
command:
- start-dev
- --import-realm
networks:
- metabase-saml
networks:
metabase-saml:
driver: bridge

View File

@@ -0,0 +1,106 @@
{
"realm": "metabase",
"enabled": true,
"sslRequired": "none",
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"users": [
{
"username": "admin",
"email": "admin@metabase.test",
"firstName": "Admin",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "admin123"
}
]
},
{
"username": "testuser",
"email": "testuser@metabase.test",
"firstName": "Internal Test",
"lastName": "User",
"enabled": true,
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "test123"
}
]
}
],
"clients": [
{
"clientId": "Metabase",
"name": "Metabase SAML Client",
"enabled": true,
"protocol": "saml",
"frontchannelLogout": true,
"attributes": {
"saml.authnstatement": "true",
"saml.server.signature": "true",
"saml.signature.algorithm": "RSA_SHA256",
"saml.client.signature": "false",
"saml.assertion.signature": "true",
"saml.encrypt": "false",
"saml_force_name_id_format": "false",
"saml.multivalued.roles": "false",
"saml_name_id_format": "email",
"saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#"
},
"redirectUris": [
"http://localhost:3000/auth/sso*",
"http://localhost:8080/auth/sso*",
"http://metabase.localhost:3000/auth/sso*"
],
"defaultClientScopes": [],
"optionalClientScopes": [],
"baseUrl": "http://metabase.localhost:3000",
"adminUrl": "http://metabase.localhost:3000",
"protocolMappers": [
{
"name": "email",
"protocol": "saml",
"protocolMapper": "saml-user-property-mapper",
"consentRequired": false,
"config": {
"attribute.nameformat": "Basic",
"user.attribute": "email",
"attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
}
},
{
"name": "firstName",
"protocol": "saml",
"protocolMapper": "saml-user-property-mapper",
"consentRequired": false,
"config": {
"attribute.nameformat": "Basic",
"user.attribute": "firstName",
"attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
}
},
{
"name": "lastName",
"protocol": "saml",
"protocolMapper": "saml-user-property-mapper",
"consentRequired": false,
"config": {
"attribute.nameformat": "Basic",
"user.attribute": "lastName",
"attribute.name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
}
}
]
}
]
}

View File

@@ -3384,6 +3384,91 @@ databaseChangeLog:
);
rollback: # nothing to undo - these rows should not exist in v59
- changeSet:
id: v59.2025-02-24T00:00:00
author: apps-hackathon
comment: Create app table for apps feature
changes:
- createTable:
tableName: app
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(254)
constraints:
nullable: false
- column:
name: auth_method
type: varchar(32)
constraints:
nullable: false
- column:
name: theme
type: varchar(254)
- column:
name: published
type: boolean
defaultValueBoolean: false
constraints:
nullable: false
- column:
name: collection_id
type: int
constraints:
nullable: false
references: collection(id)
foreignKeyName: fk_app_collection_id
- column:
name: created_at
type: timestamp
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
name: updated_at
type: timestamp
defaultValueComputed: current_timestamp
constraints:
nullable: false
- changeSet:
id: v59.2026-02-25T00:00:00
author: apps-hackathon
comment: Expand app.theme column from varchar(254) to text to support large theme objects
changes:
- modifyDataType:
tableName: app
columnName: theme
newDataType: ${text.type}
rollback:
- modifyDataType:
tableName: app
columnName: theme
newDataType: varchar(254)
- changeSet:
id: v59.2026-02-25T00:00:01
author: apps-hackathon
comment: Add logo blob column to app table
changes:
- addColumn:
tableName: app
columns:
- column:
name: logo
type: ${text.type}
rollback:
- dropColumn:
tableName: app
columnName: logo
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
########################################################################################################################

View File

@@ -19,6 +19,7 @@ const SCRIPT_TAG_PATH = path.resolve(
const BUILD_PATH = __dirname + "/resources/frontend_client";
const EMBEDDING_SRC_PATH = __dirname + "/enterprise/frontend/src/embedding";
const SDK_BUNDLE_SRC_PATH = __dirname + "/frontend/src/embedding-sdk-bundle";
const SDK_SHARED_SRC_PATH = __dirname + "/frontend/src/embedding-sdk-shared";
const OUT_FILE_NAME = "embed.js";
const OUT_TEMP_PATH = path.resolve(BUILD_PATH, "tmp-embed-js");
@@ -84,6 +85,7 @@ module.exports = {
metabase: SRC_PATH,
embedding: EMBEDDING_SRC_PATH,
"embedding-sdk-bundle": SDK_BUNDLE_SRC_PATH,
"embedding-sdk-shared": SDK_SHARED_SRC_PATH,
"sdk-iframe-embedding-script-ee-plugins": resolveEnterprisePathOrNoop(
"/sdk-iframe-embedding-script-plugins",
),

View File

@@ -11,6 +11,7 @@
[metabase.api.routes.common :as routes.common :refer [+static-apikey]]
[metabase.api.util]
[metabase.api.util.handlers :as handlers]
[metabase.apps.api]
[metabase.bookmarks.api]
[metabase.bug-reporting.api]
[metabase.cache.api]
@@ -70,6 +71,7 @@
metabase.activity-feed.api/keep-me
metabase.analytics.api/keep-me
metabase.api-keys.api/keep-me
metabase.apps.api/keep-me
metabase.api.util/keep-me
metabase.bookmarks.api/keep-me
metabase.bug-reporting.api/keep-me
@@ -150,6 +152,7 @@
"/alert" (+auth metabase.pulse.api/alert-routes)
"/analytics" (+auth 'metabase.analytics.api)
"/api-key" (+auth 'metabase.api-keys.api)
"/app" (+auth 'metabase.apps.api)
"/automagic-dashboards" (+auth metabase.xrays.api/automagic-dashboards-routes)
"/bookmark" (+auth 'metabase.bookmarks.api)
"/bug-reporting" (+auth 'metabase.bug-reporting.api)

69
src/metabase/apps/api.clj Normal file
View File

@@ -0,0 +1,69 @@
(ns metabase.apps.api
"/api/app endpoints - admin only."
(:require
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :get "/"
"List all Apps. Requires superuser."
[_route-params _query-params]
(api/check-superuser)
(t2/select :model/App {:order-by [[:%lower.name :asc]]}))
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :post "/"
"Create a new App. Requires superuser."
[_route-params
_query-params
{:keys [collection_id] :as body} :- [:map
[:name ms/NonBlankString]
[:auth_method [:enum "jwt" "saml"]]
[:collection_id ms/PositiveInt]
[:theme {:optional true} [:maybe :string]]
[:published {:optional true} [:maybe :boolean]]]]
(api/check-superuser)
(api/checkp (t2/exists? :model/Collection :id collection_id)
"collection_id" "Collection does not exist.")
(t2/insert-returning-instance! :model/App body))
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :get "/:id"
"Fetch a single App by ID. Requires superuser."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params]
(api/check-superuser)
(api/check-404 (t2/select-one :model/App :id id)))
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :put "/:id"
"Update an App. Requires superuser."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params
body :- [:map
[:name {:optional true} ms/NonBlankString]
[:auth_method {:optional true} [:enum "jwt" "saml"]]
[:collection_id {:optional true} ms/PositiveInt]
[:theme {:optional true} [:maybe :string]]
[:published {:optional true} [:maybe :boolean]]]]
(api/check-superuser)
(api/check-404 (t2/select-one :model/App :id id))
(when-let [collection-id (:collection_id body)]
(api/checkp (t2/exists? :model/Collection :id collection-id)
"collection_id" "Collection does not exist."))
(t2/update! :model/App id body)
(t2/select-one :model/App :id id))
#_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]}
(api.macros/defendpoint :delete "/:id"
"Delete an App. Requires superuser."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
_query-params]
(api/check-superuser)
(api/check-404 (t2/select-one :model/App :id id))
(t2/delete! :model/App :id id)
api/generic-204-no-content)

View File

@@ -0,0 +1,17 @@
(ns metabase.apps.models.app
"`:model/App` - Toucan2 model for the `app` table."
(:require
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(methodical/defmethod t2/table-name :model/App [_model] :app)
(doto :model/App
(derive :metabase/model)
(derive :hook/timestamped?))
(t2/deftransforms :model/App
{:auth_method mi/transform-keyword})

View File

@@ -16,6 +16,7 @@
:model/AnalysisFinding metabase-enterprise.dependencies.models.analysis-finding
:model/AnalysisFindingError metabase-enterprise.dependencies.models.analysis-finding-error
:model/ApiKey metabase.api-keys.models.api-key
:model/App metabase.apps.models.app
:model/ApplicationPermissionsRevision metabase.permissions.models.application-permissions-revision
:model/AuditLog metabase.audit-app.models.audit-log
:model/AuthIdentity metabase.auth-identity.models.auth-identity

View File

@@ -11,6 +11,7 @@
[metabase.query-processor.schema :as qp.schema]
[metabase.server.auth-wrapper :as auth-wrapper]
[metabase.server.middleware.embedding-sdk-bundle :as mw.embedding-sdk-bundle]
[metabase.server.routes.apps :as apps]
[metabase.server.routes.index :as index]
[metabase.system.core :as system]
[metabase.util :as u]
@@ -119,6 +120,8 @@
(context "/public" [] public-routes)
;; ^/emebed/ -> Embed frontend and download routes
(context "/embed" [] embed-routes)
;; ^/apps/:name -> Custom app pages with embedded dashboards
(GET "/apps/:name" [] apps/app-handler)
;; Anything else (e.g. /user/edit_current) should serve up index.html; React app will handle the rest
(GET "*" [] index/index)))

View File

@@ -0,0 +1,121 @@
(ns metabase.server.routes.apps
"Serve custom app pages with embedded Metabase collection browser using modular embedding SDK."
(:require
[metabase.config.core :as config]
[metabase.initialization-status.core :as init-status]
[metabase.util :as u]
[ring.util.response :as response]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- get-app-by-name
"Find an App by its name (case-insensitive)."
[app-name]
(t2/select-one :model/App :%lower.name (u/lower-case-en app-name)))
(defn- app-page-html
"Generate HTML page with embedded collection browser."
[app-name api-key auth-method theme logo collection-id use-current-session]
(str "<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>" app-name " - Metabase App</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
#app-container { height: 100vh; width: 100%; display: flex; flex-direction: column; }
#app-container > * { flex: 1; height: 100%; }
app-wrapper { display: flex; flex-direction: column; }
.app-header {
padding: 16px 24px;
font-size: 20px;
font-weight: 600;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
}
.app-header img {
height: 32px;
width: auto;
}
.app-header-title { flex: 1; }
.app-user {
font-size: 14px;
font-weight: 400;
opacity: 0.7;
}
.app-content { flex: 1; overflow: hidden; }
</style>
</head>
<body>
<div id=\"app-container\">
<app-wrapper>
<div class=\"app-header\">"
(when logo
(str "<img src=\"" logo "\" alt=\"\" />"))
"<span class=\"app-header-title\">" app-name "</span>
<span class=\"app-user\"></span>
</div>
<div class=\"app-content\">
<metabase-browser initial-collection=\"" collection-id "\"></metabase-browser>
</div>
</app-wrapper>
</div>
<script>
var INSTANCE_URL = window.location.origin;
var script = document.createElement('script');
script.src = INSTANCE_URL + '/app/embed.js';
script.defer = true;
document.head.appendChild(script);
</script>
<script>
function defineMetabaseConfig(config) {
window.metabaseConfig = config;
}
</script>
<script>
defineMetabaseConfig({
instanceUrl: window.location.origin"
(cond
api-key (str ",\n apiKey: \"" api-key "\"")
use-current-session nil
:else (str ",\n preferredAuthMethod: \"" (name auth-method) "\""))
(when theme
(str ",\n theme: " theme))
(when use-current-session
",\n \"useExistingUserSession\": true")
"
});
</script>
</body>
</html>"))
(defn app-handler
"Handler for /apps/:name routes. Serves an HTML page with embedded collection browser.
Looks up the App by name and uses its collection_id.
API key can be provided via ?api_key query param or MB_APPS_API_KEY env var."
[request respond _raise]
(if-not (init-status/complete?)
(respond {:status 503 :body "Metabase is still initializing..."})
(let [app-name (get-in request [:route-params :name] "App")
app (get-app-by-name app-name)]
(if-not app
(respond {:status 404 :body (str "App not found: " app-name)})
(let [api-key (or (get-in request [:params :api_key])
(config/config-str :mb-apps-api-key))
auth-method (:auth_method app)
theme (:theme app)
logo (:logo app)
coll-id (:collection_id app)
use-current-session (= "true" (get-in request [:params :useCurrentSession]))]
(respond
(-> (response/response (app-page-html (:name app) api-key auth-method theme logo coll-id use-current-session))
(response/content-type "text/html; charset=utf-8")
(response/header "Content-Security-Policy"
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src *; frame-src *; img-src * data: blob:;"))))))))