Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to

### Added

- ✨(frontend) new custom block "embed" #2513
- ♿️(frontend) restore skip to content link after header redesign #2510

## [v5.4.1] - 2026-07-09
Expand Down
2 changes: 2 additions & 0 deletions documentation/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ These are the environment variables you can set for the `impress-backend` contai
| FRONTEND_CSS_URL | To add a external css file to the app | |
| FRONTEND_JS_URL | To add a external js file to the app | |
| FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false |
| FRONTEND_EMBED_BLOCK_ENABLED | Frontend feature flag to allow inserting the "embed" block | true |
| FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS | Allowlist mapping each embeddable host to its iframe sandbox, e.g. `{"excalidraw.com": "allow-scripts allow-same-origin"}`. Each key matches only that exact host; prefix with `*.` to also cover its subdomains, or use a bare `*` to allow any host | {"*": "allow-scripts allow-same-origin"} |
| FRONTEND_THEME | Frontend theme to use | |
| LANGUAGE_CODE | Default language | en-us |
| LANGFUSE_SECRET_KEY | The Langfuse secret key used by the sdk | None |
Expand Down
2 changes: 2 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3087,6 +3087,8 @@ def get(self, request):
"CONVERSION_UPLOAD_ENABLED",
"ENVIRONMENT",
"FRONTEND_CSS_URL",
"FRONTEND_EMBED_BLOCK_ENABLED",
"FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS",
"FRONTEND_HOMEPAGE_FEATURE_ENABLED",
"FRONTEND_JS_URL",
"FRONTEND_SILENT_LOGIN_ENABLED",
Expand Down
8 changes: 8 additions & 0 deletions src/backend/core/tests/test_api_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
COLLABORATION_WS_INACTIVITY_TIMEOUT=300,
CONVERSION_UPLOAD_ENABLED=False,
FRONTEND_CSS_URL="http://testcss/",
FRONTEND_EMBED_BLOCK_ENABLED=True,
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS={
"excalidraw.com": "allow-scripts allow-same-origin",
},
FRONTEND_JS_URL="http://testjs/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
Expand Down Expand Up @@ -63,6 +67,10 @@ def test_api_config(is_authenticated):
"CONVERSION_UPLOAD_ENABLED": False,
"ENVIRONMENT": "test",
"FRONTEND_CSS_URL": "http://testcss/",
"FRONTEND_EMBED_BLOCK_ENABLED": True,
"FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS": {
"excalidraw.com": "allow-scripts allow-same-origin",
},
"FRONTEND_HOMEPAGE_FEATURE_ENABLED": True,
"FRONTEND_JS_URL": "http://testjs/",
"FRONTEND_SILENT_LOGIN_ENABLED": False,
Expand Down
22 changes: 22 additions & 0 deletions src/backend/impress/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,28 @@ class Base(Configuration):
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
)
FRONTEND_EMBED_BLOCK_ENABLED = values.BooleanValue(
default=True, environ_name="FRONTEND_EMBED_BLOCK_ENABLED", environ_prefix=None
)
# Allowlist of origins that may be embedded through the "embed" custom block,
# mapping each allowed host to the sandbox attribute to apply to its iframe.
# A URL whose host is not covered here is refused by the frontend.
# Example:
# {
# "grist.numerique.gouv.fr": "allow-scripts",
# "*.numerique.gouv.fr": "allow-scripts allow-same-origin",
# }
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS = values.DictValue(
default={
"*": (
"allow-scripts allow-same-origin allow-popups "
"allow-popups-to-escape-sandbox allow-forms"
)
},
environ_name="FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS",
environ_prefix=None,
)

THEME_CUSTOMIZATION_FILE_PATH = values.Value(
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
Expand Down
140 changes: 137 additions & 3 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';

import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
import {
createDoc,
goToGridDoc,
overrideConfig,
verifyDocName,
} from './utils-common';
import {
getEditor,
openSuggestionMenu,
tryFocusEditorContent,
writeInEditor,
} from './utils-editor';
import { updateShareLink } from './utils-share';
import {
createRootSubPage,
Expand Down Expand Up @@ -544,7 +554,7 @@ test.describe('Doc Editor', () => {
});

test('it embeds PDF', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
await createDoc(page, 'doc-embed-pdf', browserName, 1);

await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Reading');
Expand Down Expand Up @@ -637,4 +647,128 @@ test.describe('Doc Editor', () => {

await expect(editor.getByText('Mobile Text')).toBeVisible();
});

test('it embeds a web page', async ({ page, browserName }) => {
await createDoc(page, 'doc-embed-web', browserName, 1);

await openSuggestionMenu({ page, suggestion: 'Embed a web page' });

const embedBlock = page.locator('div[data-content-type="embed"]').last();

await expect(embedBlock).toBeVisible();

// Try with same domain first
await page
.getByText(/Add embed/)
.first()
.click();

await page
.locator('[data-test="embed-input"]')
.fill('http://localhost:3000/');

await page.locator('[data-test="embed-input-button"]').click();

await expect(page.getByText('Invalid or unsafe URL.')).toBeVisible();

await openSuggestionMenu({ page, suggestion: 'Embed a web page' });

// Now with a valid URL
await page
.getByText(/Add embed/)
.first()
.click();

await page
.locator('[data-test="embed-input"]')
.fill('http://localhost:3001/');
await page.locator('[data-test="embed-input-button"]').click();

const embedIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
.first();

// Check src of embed iframe
expect(await embedIframe.getAttribute('src')).toMatch(
/http:\/\/localhost:3001\//,
);

await expect(embedIframe).toHaveAttribute('role', 'presentation');
});

test('it controls the embeds of a web page with configuration', async ({
page,
browserName,
}) => {
// Disable the embed block feature to test the configuration
await overrideConfig(page, {
FRONTEND_EMBED_BLOCK_ENABLED: false,
});

await createDoc(page, 'doc-embed-web-configuration', browserName, 1);

await tryFocusEditorContent({ page });
await page.keyboard.press('Enter');
await page.keyboard.type('/');

await expect(
page.locator('.bn-suggestion-menu').getByText('Embed a web page'),
).toBeHidden();

await overrideConfig(page, {
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS: {
'http://localhost:3001': 'allow-scripts',
},
});

await page.reload();

await openSuggestionMenu({ page, suggestion: 'Embed a web page' });

const embedBlock = page.locator('div[data-content-type="embed"]').last();

await expect(embedBlock).toBeVisible();

// Try with a domain that is not allowed first
await page
.getByText(/Add embed/)
.first()
.click();

await page
.locator('[data-test="embed-input"]')
.fill('http://localhost:3002/');

await page.locator('[data-test="embed-input-button"]').click();

await expect(
page.getByText(
'This domain is not allowed for embedding. Please contact your administrator to have it added to the list of allowed domains.',
),
).toBeVisible();

await openSuggestionMenu({ page, suggestion: 'Embed a web page' });

// Now with a valid URL
await page
.getByText(/Add embed/)
.first()
.click();

await page
.locator('[data-test="embed-input"]')
.fill('http://localhost:3001/');
await page.locator('[data-test="embed-input-button"]').click();

const embedIframe = page
.locator('.--docs--editor-container iframe.bn-visual-media')
.first();

// Check src of embed iframe
expect(await embedIframe.getAttribute('src')).toMatch(
/http:\/\/localhost:3001\//,
);
await expect(embedIframe).toHaveAttribute('sandbox', 'allow-scripts');
await expect(embedIframe).toHaveAttribute('role', 'presentation');
});
});
4 changes: 4 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export const CONFIG = {
CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'],
CONVERSION_FILE_MAX_SIZE: 20971520,
ENVIRONMENT: 'development',
FRONTEND_EMBED_BLOCK_ENABLED: true,
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS: {
'*': 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms',
},
FRONTEND_CSS_URL: null,
FRONTEND_JS_URL: null,
FRONTEND_HOMEPAGE_FEATURE_ENABLED: true,
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/apps/impress/src/core/config/api/useConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ interface ThemeCustomization {
waffle?: LaGaufreV2Props;
}

/**
* Map of an allowed embed host to the iframe `sandbox` attribute to apply.
* Record<host, sandboxAttribute>
* Example:
* {
* "excalidraw.com": "allow-scripts allow-same-origin",
* "www.tldraw.com": "allow-scripts allow-same-origin",
* }
*/
export type EmbedAllowedOrigins = Record<string, string>;

export interface ConfigResponse {
AI_BOT: { name: string; color: string };
AI_FEATURE_ENABLED?: boolean;
Expand All @@ -56,6 +67,8 @@ export interface ConfigResponse {
CONVERSION_UPLOAD_ENABLED?: boolean;
ENVIRONMENT: string;
FRONTEND_CSS_URL?: string;
FRONTEND_EMBED_BLOCK_ENABLED?: boolean;
FRONTEND_EMBED_BLOCK_ALLOWED_ORIGINS?: EmbedAllowedOrigins;
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
FRONTEND_JS_URL?: string;
FRONTEND_SILENT_LOGIN_ENABLED?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as localesBN from '@blocknote/core/locales';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import {
FilePanelController,
FloatingComposerController,
FloatingThreadController,
ThreadsSidebar,
Expand Down Expand Up @@ -53,7 +54,13 @@ import { randomColor, sanitizeColor } from '../utils';
import BlockNoteAI from './AI';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { CalloutBlock, PdfBlock, UploadLoaderBlock } from './custom-blocks';
import {
CalloutBlock,
DocsFilePanel,
EmbedBlock,
PdfBlock,
UploadLoaderBlock,
} from './custom-blocks';
const AIMenu = BlockNoteAI?.AIMenu;
const AIMenuController = BlockNoteAI?.AIMenuController;
const useAI = BlockNoteAI?.useAI;
Expand All @@ -70,6 +77,7 @@ const baseBlockNoteSchema = withPageBreak(
...defaultBlockSpecs,
callout: CalloutBlock(),
codeBlock: createCodeBlockSpec(codeBlockOptions),
embed: EmbedBlock(),
pdf: PdfBlock(),
uploadLoader: UploadLoaderBlock(),
},
Expand Down Expand Up @@ -292,6 +300,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
slashMenu={false}
filePanel={false}
theme="light"
comments={false}
aria-label={t('Document editor')}
Expand All @@ -303,6 +312,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
)}
<BlockNoteSuggestionMenu aiAllowed={aiBlockNoteAllowed} />
<BlockNoteToolbar aiAllowed={aiBlockNoteAllowed} />
<FilePanelController filePanel={DocsFilePanel} />
{showComments && <FloatingComposerController />}
{showComments && !isCommentSideBarOpen && <FloatingThreadController />}
{threadsSidebarTarget &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import { useConfig } from '@/core';

import {
DocsBlockSchema,
DocsInlineContentSchema,
Expand All @@ -20,6 +22,7 @@ import {
import BlockNoteAI from './AI';
import {
getCalloutReactSlashMenuItems,
getEmbedReactSlashMenuItems,
getPdfReactSlashMenuItems,
} from './custom-blocks';
import { useGetInterlinkingMenuItems } from './custom-inline-content';
Expand All @@ -44,6 +47,7 @@ export const BlockNoteSuggestionMenu = ({
const dictionaryDate = useDictionary();
const basicBlocksName = dictionaryDate.slash_menu.page_break.group;
const fileBlocksName = dictionaryDate.slash_menu.file.group;
const embedEnabled = useConfig().data?.FRONTEND_EMBED_BLOCK_ENABLED ?? false;

const getInterlinkingMenuItems = useGetInterlinkingMenuItems();

Expand All @@ -56,6 +60,9 @@ export const BlockNoteSuggestionMenu = ({
getPageBreakReactSlashMenuItems(editor),
getMultiColumnSlashMenuItems?.(editor) || [],
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
embedEnabled
? getEmbedReactSlashMenuItems(editor, t, fileBlocksName)
: [],
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
aiAllowed && getAISlashMenuItems ? getAISlashMenuItems(editor) : [],
);
Expand All @@ -80,6 +87,7 @@ export const BlockNoteSuggestionMenu = ({
fileBlocksName,
basicBlocksName,
aiAllowed,
embedEnabled,
getInterlinkingMenuItems,
]);

Expand Down
Loading
Loading