Compare commits
29 Commits
persistent
...
apps-hacka
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bea46f52e0 | ||
|
|
42bc195e2e | ||
|
|
92d7feaa3c | ||
|
|
6fe18b3833 | ||
|
|
1e0e608218 | ||
|
|
db68574374 | ||
|
|
41b01547c9 | ||
|
|
3ac73f01cd | ||
|
|
7da0f5daa2 | ||
|
|
dd572a7582 | ||
|
|
53298db1c5 | ||
|
|
a95f0efa4e | ||
|
|
6b6a958cd1 | ||
|
|
d358eddbe4 | ||
|
|
5b164898e2 | ||
|
|
d70fc2dd06 | ||
|
|
c9615d516c | ||
|
|
4ec40c7fce | ||
|
|
e0268e7a7a | ||
|
|
0ae33f2eac | ||
|
|
1cca634804 | ||
|
|
feb13408d9 | ||
|
|
6dbcdb0e1b | ||
|
|
8f1306462f | ||
|
|
c470148d39 | ||
|
|
ee898240e0 | ||
|
|
5aa44c8099 | ||
|
|
1a164ac89e | ||
|
|
675776d273 |
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AdHocQuestion";
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AdHocQuestion";
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -18,7 +18,8 @@ export type AdminPathKey =
|
||||
| "performance"
|
||||
| "performance-models"
|
||||
| "performance-dashboards-and-questions"
|
||||
| "performance-databases";
|
||||
| "performance-databases"
|
||||
| "apps";
|
||||
|
||||
export type AdminPath = {
|
||||
key: AdminPathKey;
|
||||
|
||||
@@ -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",
|
||||
|
||||
603
frontend/src/metabase/admin/apps/components/AppForm.tsx
Normal file
603
frontend/src/metabase/admin/apps/components/AppForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
frontend/src/metabase/admin/apps/components/AppsList.tsx
Normal file
103
frontend/src/metabase/admin/apps/components/AppsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/src/metabase/admin/apps/containers/AdminAppsApp.tsx
Normal file
11
frontend/src/metabase/admin/apps/containers/AdminAppsApp.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
55
frontend/src/metabase/api/app.ts
Normal file
55
frontend/src/metabase/api/app.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -4,6 +4,7 @@ export const TAG_TYPES = [
|
||||
"action",
|
||||
"alert",
|
||||
"api-key",
|
||||
"app",
|
||||
"bookmark",
|
||||
"cache-config",
|
||||
"card",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
75
keycloak/README.md
Normal 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
|
||||
23
keycloak/docker-compose.yml
Normal file
23
keycloak/docker-compose.yml
Normal 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
|
||||
106
keycloak/metabase-realm.json
Normal file
106
keycloak/metabase-realm.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 <<<<<<<<<<
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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
69
src/metabase/apps/api.clj
Normal 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)
|
||||
17
src/metabase/apps/models/app.clj
Normal file
17
src/metabase/apps/models/app.clj
Normal 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})
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
121
src/metabase/server/routes/apps.clj
Normal file
121
src/metabase/server/routes/apps.clj
Normal 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:;"))))))))
|
||||
Reference in New Issue
Block a user