From 0d6538ab1a38fe395827b737570130486f9e14c7 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 18 May 2026 22:02:31 +0100 Subject: [PATCH 01/39] feat: iframe configuration --- .env.example | 7 ++++++ apps/server/src/common/helpers/index.ts | 1 + .../src/common/helpers/security-headers.ts | 19 +++++++++++++++ .../environment/environment.service.ts | 15 ++++++++++++ apps/server/src/main.ts | 24 +++++++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 apps/server/src/common/helpers/security-headers.ts diff --git a/.env.example b/.env.example index b218bdb84..cf2dafc1d 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,13 @@ GOTENBERG_URL= DISABLE_TELEMETRY=false +# Allow other sites to embed Docmost in an iframe. +IFRAME_EMBED_ALLOWED=false + +# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed. +# Example: https://intranet.example.com,https://portal.example.com +IFRAME_ALLOWED_ORIGINS= + # Enable debug logging in production (default: false) DEBUG_MODE=false diff --git a/apps/server/src/common/helpers/index.ts b/apps/server/src/common/helpers/index.ts index 13e44d526..80e9a9028 100644 --- a/apps/server/src/common/helpers/index.ts +++ b/apps/server/src/common/helpers/index.ts @@ -2,3 +2,4 @@ export * from './utils'; export * from './nanoid.utils'; export * from './file.helper'; export * from './constants'; +export * from './security-headers'; diff --git a/apps/server/src/common/helpers/security-headers.ts b/apps/server/src/common/helpers/security-headers.ts new file mode 100644 index 000000000..931300e2d --- /dev/null +++ b/apps/server/src/common/helpers/security-headers.ts @@ -0,0 +1,19 @@ +export type SecurityHeader = { name: string; value: string }; + +export function resolveFrameHeader( + iframeEmbedAllowed: boolean, + allowedOrigins: string[], +): SecurityHeader | null { + if (!iframeEmbedAllowed) { + return { name: 'X-Frame-Options', value: 'SAMEORIGIN' }; + } + + if (allowedOrigins.length === 0) { + return null; + } + + return { + name: 'Content-Security-Policy', + value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`, + }; +} diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index abee1966f..20824d355 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -325,4 +325,19 @@ export class EnvironmentService { .toLowerCase(); return disabled === 'true'; } + + isIframeEmbedAllowed(): boolean { + const allowed = this.configService + .get('IFRAME_EMBED_ALLOWED', 'false') + .toLowerCase(); + return allowed === 'true'; + } + + getIframeAllowedOrigins(): string[] { + const raw = this.configService.get('IFRAME_ALLOWED_ORIGINS', ''); + return raw + .split(',') + .map((o) => o.trim()) + .filter(Boolean); + } } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 877411223..1c2ccebf1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -12,6 +12,8 @@ import fastifyMultipart from '@fastify/multipart'; import fastifyCookie from '@fastify/cookie'; import fastifyIp from 'fastify-ip'; import { InternalLogFilter } from './common/logger/internal-log-filter'; +import { EnvironmentService } from './integrations/environment/environment.service'; +import { resolveFrameHeader } from './common/helpers'; async function bootstrap() { const app = await NestFactory.create( @@ -50,6 +52,28 @@ async function bootstrap() { await app.register(fastifyMultipart); await app.register(fastifyCookie); + const environmentService = app.get(EnvironmentService); + const frameHeader = resolveFrameHeader( + environmentService.isIframeEmbedAllowed(), + environmentService.getIframeAllowedOrigins(), + ); + if (frameHeader) { + // Skipped routes: + // /api/files/ - attachment controller sets its own CSP we'd overwrite + // /share/ 0 public share pages are safe to embed + const frameHeaderSkippedPrefixes = ['/api/files/', '/share/']; + app + .getHttpAdapter() + .getInstance() + .addHook('onSend', (req, reply, payload, done) => { + if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) { + return done(null, payload); + } + reply.header(frameHeader.name, frameHeader.value); + done(null, payload); + }); + } + app .getHttpAdapter() .getInstance() From 6cf8101ab3661c00e1b960739377d4c2bb71e73d Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 19 May 2026 02:41:52 +0100 Subject: [PATCH 02/39] feat(ee): templates (#2215) * feat(ee): templates * fix tree * fix --- .../public/locales/en-US/translation.json | 29 ++ .../layouts/global/global-sidebar.module.css | 10 + .../layouts/global/global-sidebar.tsx | 68 +++-- .../destination-picker-modal.tsx | 4 + .../destination-picker.module.css | 10 +- .../destination-picker/destination-picker.tsx | 88 +++++- .../destination-picker.types.ts | 2 + .../ui/destination-picker/page-row.tsx | 44 ++- .../ui/destination-picker/space-row.tsx | 32 ++- .../components/allow-member-templates.tsx | 10 +- .../client/src/ee/security/pages/security.tsx | 1 - .../components/template-card.module.css | 20 +- .../ee/template/components/template-card.tsx | 28 +- .../template-picker-modal.module.css | 70 +++++ .../components/template-picker-modal.tsx | 259 ++++++++++++++++++ .../components/template-preview-modal.tsx | 13 +- .../components/use-template-modal.tsx | 4 + .../src/ee/template/pages/template-editor.tsx | 12 + .../src/ee/template/pages/template-list.tsx | 3 +- .../src/ee/template/queries/template-query.ts | 70 ++++- .../ee/template/services/template-service.ts | 5 +- .../features/editor/extensions/extensions.ts | 4 +- .../components/sidebar/space-sidebar.tsx | 32 +++ .../settings/workspace/workspace-settings.tsx | 4 + .../database/repos/template/template.repo.ts | 6 +- 25 files changed, 752 insertions(+), 76 deletions(-) create mode 100644 apps/client/src/ee/template/components/template-picker-modal.module.css create mode 100644 apps/client/src/ee/template/components/template-picker-modal.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index d59aa94b9..62927f66a 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -936,6 +936,35 @@ "Page actions": "Page actions", "Pick emoji": "Pick emoji", "Template menu": "Template menu", + "Use": "Use", + "Use template": "Use template", + "Preview template: {{title}}": "Preview template: {{title}}", + "Use a template": "Use a template", + "Search templates...": "Search templates...", + "Search spaces...": "Search spaces...", + "No templates found": "No templates found", + "No spaces found": "No spaces found", + "Browse all templates": "Browse all templates", + "This space": "This space", + "All templates": "All templates", + "Global": "Global", + "New template": "New template", + "Edit template": "Edit template", + "Are you sure you want to delete this template?": "Are you sure you want to delete this template?", + "Template scope updated": "Template scope updated", + "Choose which space this template belongs to": "Choose which space this template belongs to", + "Scope": "Scope", + "Select scope": "Select scope", + "Title": "Title", + "Saving...": "Saving...", + "Saved": "Saved", + "Save failed. Retry": "Save failed. Retry", + "By {{name}}": "By {{name}}", + "Updated {{time}}": "Updated {{time}}", + "Choose destination": "Choose destination", + "Search pages and spaces...": "Search pages and spaces...", + "No results found": "No results found", + "You don't have permission to create pages here": "You don't have permission to create pages here", "Chat menu": "Chat menu", "API key menu": "API key menu", "Jump to comment selection": "Jump to comment selection", diff --git a/apps/client/src/components/layouts/global/global-sidebar.module.css b/apps/client/src/components/layouts/global/global-sidebar.module.css index 9a7bc1b6f..1385ec20d 100644 --- a/apps/client/src/components/layouts/global/global-sidebar.module.css +++ b/apps/client/src/components/layouts/global/global-sidebar.module.css @@ -38,6 +38,16 @@ color: light-dark(var(--mantine-color-black), var(--mantine-color-white)); } } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.5; + + @mixin hover { + background-color: transparent; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1)); + } + } } .linkIcon { diff --git a/apps/client/src/components/layouts/global/global-sidebar.tsx b/apps/client/src/components/layouts/global/global-sidebar.tsx index 5019d5d98..6882f81d4 100644 --- a/apps/client/src/components/layouts/global/global-sidebar.tsx +++ b/apps/client/src/components/layouts/global/global-sidebar.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core"; +import { ScrollArea, Text, Divider, Modal, UnstyledButton, Tooltip } from "@mantine/core"; import { IconHome, IconClock, @@ -7,6 +7,7 @@ import { IconLayoutGrid, IconSettings, IconUserPlus, + IconTemplate, } from "@tabler/icons-react"; import { Link, useLocation } from "react-router-dom"; import classes from "./global-sidebar.module.css"; @@ -20,12 +21,9 @@ import { useDisclosure } from "@mantine/hooks"; import { WorkspaceInviteForm } from "@/features/workspace/components/members/components/workspace-invite-form"; import { CustomAvatar } from "@/components/ui/custom-avatar"; import { AvatarIconType } from "@/features/attachments/types/attachment.types"; - -const mainNavItems = [ - { label: "Home", icon: IconHome, path: "/home" }, - { label: "Favorites", icon: IconStar, path: "/favorites" }, - { label: "Spaces", icon: IconLayoutGrid, path: "/spaces" }, -]; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; export default function GlobalSidebar() { const { t } = useTranslation(); @@ -33,6 +31,19 @@ export default function GlobalSidebar() { const [active, setActive] = useState(location.pathname); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); + const hasTemplates = useHasFeature(Feature.TEMPLATES); + const upgradeLabel = useUpgradeLabel(); + const mainNavItems = [ + { label: "Home", icon: IconHome, path: "/home" }, + { label: "Favorites", icon: IconStar, path: "/favorites" }, + { label: "Spaces", icon: IconLayoutGrid, path: "/spaces" }, + { + label: "Templates", + icon: IconTemplate, + path: "/templates", + disabled: !hasTemplates, + }, + ]; const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space"); const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? []; const sortedFavoriteSpaces = [...favoriteSpaces] @@ -58,18 +69,37 @@ export default function GlobalSidebar() {
- {mainNavItems.map((item) => ( - - - {t(item.label)} - - ))} + {mainNavItems.map((item) => + item.disabled ? ( + + + + {t(item.label)} + + + ) : ( + + + {t(item.label)} + + ), + )}
diff --git a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx index 04e9ef7dd..21c906969 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx +++ b/apps/client/src/components/ui/destination-picker/destination-picker-modal.tsx @@ -16,6 +16,8 @@ export function DestinationPickerModal({ loading, excludePageId, pageLimit, + initialSpaceId, + searchSpacesOnly, }: DestinationPickerModalProps) { const { t } = useTranslation(); const [selection, setSelection] = useState(null); @@ -46,6 +48,8 @@ export function DestinationPickerModal({ onSelectionChange={setSelection} excludePageId={excludePageId} pageLimit={pageLimit} + initialSpaceId={initialSpaceId} + searchSpacesOnly={searchSpacesOnly} /> diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.module.css b/apps/client/src/components/ui/destination-picker/destination-picker.module.css index ec598ac50..fb868bc14 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker.module.css +++ b/apps/client/src/components/ui/destination-picker/destination-picker.module.css @@ -13,6 +13,7 @@ display: flex; align-items: center; gap: 8px; + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); transition: background-color 150ms ease; user-select: none; @@ -22,6 +23,11 @@ var(--mantine-color-dark-6) ); } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } } .selected { @@ -57,7 +63,7 @@ border-radius: var(--mantine-radius-sm); flex-shrink: 0; transition: transform 150ms ease; - color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); @mixin hover { background-color: light-dark( @@ -111,7 +117,7 @@ } .spaceName { - color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); + color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2)); font-size: var(--mantine-font-size-xs); flex-shrink: 0; } diff --git a/apps/client/src/components/ui/destination-picker/destination-picker.tsx b/apps/client/src/components/ui/destination-picker/destination-picker.tsx index 1ddc747dd..b16a25a48 100644 --- a/apps/client/src/components/ui/destination-picker/destination-picker.tsx +++ b/apps/client/src/components/ui/destination-picker/destination-picker.tsx @@ -1,7 +1,7 @@ -import { useState, useCallback } from "react"; -import { TextInput, ScrollArea, Loader } from "@mantine/core"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { ActionIcon, TextInput, ScrollArea, Loader } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; -import { IconSearch, IconFile } from "@tabler/icons-react"; +import { IconSearch, IconFileDescription } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; @@ -15,23 +15,29 @@ type DestinationPickerProps = { onSelectionChange: (selection: DestinationSelection | null) => void; excludePageId?: string; pageLimit?: number; + initialSpaceId?: string; + searchSpacesOnly?: boolean; }; export function DestinationPicker({ onSelectionChange, excludePageId, pageLimit = 15, + initialSpaceId, + searchSpacesOnly, }: DestinationPickerProps) { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(""); const [selection, setSelection] = useState(null); const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + const viewportRef = useRef(null); const { data: spacesData, isLoading: spacesLoading } = useGetSpacesQuery({ limit: 100, }); - const searchEnabled = debouncedQuery && debouncedQuery.length >= 2; + const searchEnabled = + !searchSpacesOnly && debouncedQuery && debouncedQuery.length >= 2; const { data: searchData, isLoading: searchLoading } = useSearchSuggestionsQuery({ @@ -42,6 +48,18 @@ export function DestinationPicker({ const isSearching = !!searchEnabled; + const filteredSpaces = useMemo(() => { + const items = spacesData?.items ?? []; + if (!searchSpacesOnly || !debouncedQuery) return items; + const fold = (s: string) => + s + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLocaleLowerCase(); + const term = fold(debouncedQuery); + return items.filter((s) => fold(s.name).includes(term)); + }, [spacesData, searchSpacesOnly, debouncedQuery]); + const selectedId = selection?.type === "space" ? selection.spaceId : selection?.pageId ?? null; @@ -87,18 +105,48 @@ export function DestinationPicker({ [updateSelection], ); + // Pre-select space when initialSpaceId is set and spaces have loaded. + // Only runs once: skip if user has already made a selection. + useEffect(() => { + if (!initialSpaceId || selection) return; + const match = spacesData?.items?.find((s) => s.id === initialSpaceId); + if (match) { + updateSelection({ type: "space", spaceId: match.id, space: match }); + requestAnimationFrame(() => { + const el = viewportRef.current?.querySelector( + `[data-space-id="${match.id}"]`, + ); + el?.scrollIntoView({ block: "nearest" }); + }); + } + }, [initialSpaceId, selection, spacesData, updateSelection]); + return ( <> } - placeholder={t("Search pages and spaces...")} + placeholder={ + searchSpacesOnly + ? t("Search spaces...") + : t("Search pages and spaces...") + } + aria-label={ + searchSpacesOnly + ? t("Search spaces...") + : t("Search pages and spaces...") + } variant="filled" value={searchQuery} onChange={(e) => setSearchQuery(e.currentTarget.value)} className={classes.searchInput} /> - + {isSearching ? ( searchLoading ? (
@@ -111,16 +159,28 @@ export function DestinationPicker({
handleSearchResultClick(page)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSearchResultClick(page); + } + }} >
{page.icon ? ( page.icon ) : ( - + + + )}
@@ -141,8 +201,14 @@ export function DestinationPicker({
+ ) : filteredSpaces.length === 0 ? ( +
+ {searchSpacesOnly && debouncedQuery + ? t("No spaces found") + : t("No results found")} +
) : ( - spacesData?.items?.map((space) => ( + filteredSpaces.map((space) => ( { + if (!isExcluded) onSelect(page); + }; + + const handleRowKeyDown = (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelect(); + } + }; + return ( <>
!isExcluded && onSelect(page)} + role="button" + tabIndex={isExcluded ? -1 : 0} + aria-disabled={isExcluded || undefined} + onClick={handleSelect} + onKeyDown={handleRowKeyDown} > {page.hasChildren ? ( -
{ e.stopPropagation(); setExpanded(!expanded); }} > -
+ ) : (
)} @@ -61,10 +83,14 @@ export function PageRow({ {page.icon ? ( page.icon ) : ( - + + + )}
diff --git a/apps/client/src/components/ui/destination-picker/space-row.tsx b/apps/client/src/components/ui/destination-picker/space-row.tsx index 59273af7e..857f18f6c 100644 --- a/apps/client/src/components/ui/destination-picker/space-row.tsx +++ b/apps/client/src/components/ui/destination-picker/space-row.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Tooltip } from "@mantine/core"; +import { KeyboardEvent, useState } from "react"; +import { ActionIcon, Tooltip } from "@mantine/core"; import { IconChevronRight, IconLock } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { ISpace } from "@/features/space/types/space.types"; @@ -42,21 +42,43 @@ export function SpaceRow({ .filter(Boolean) .join(" "); + const handleSelect = () => { + if (writable) onSelectSpace(space); + }; + + const handleRowKeyDown = (e: KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelect(); + } + }; + const rowContent = (
writable && onSelectSpace(space)} + data-space-id={space.id} + role="button" + tabIndex={writable ? 0 : -1} + aria-disabled={!writable || undefined} + onClick={handleSelect} + onKeyDown={handleRowKeyDown} > {writable ? ( -
{ e.stopPropagation(); setExpanded(!expanded); }} > -
+ ) : (
)} diff --git a/apps/client/src/ee/security/components/allow-member-templates.tsx b/apps/client/src/ee/security/components/allow-member-templates.tsx index f547d1644..8acc8ba6d 100644 --- a/apps/client/src/ee/security/components/allow-member-templates.tsx +++ b/apps/client/src/ee/security/components/allow-member-templates.tsx @@ -34,7 +34,7 @@ function AllowMemberTemplatesToggle() { const [checked, setChecked] = useState( workspace?.settings?.templates?.allowMemberTemplates === true, ); - const hasSecuritySettings = useHasFeature(Feature.SECURITY_SETTINGS); + const hasTemplates = useHasFeature(Feature.TEMPLATES); const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { @@ -54,15 +54,11 @@ function AllowMemberTemplatesToggle() { }; return ( - + diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index ee6343516..2ff3670be 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -137,7 +137,6 @@ export default function Security() { { max: SCIM_TOKEN_LIMIT }, )} disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT} - refProp="rootRef" > {canManage && ( @@ -91,6 +114,7 @@ export default function TemplateCard({
{template.title}
+