From 66c26af34bcee1d9b50b21acd45861a1fbee515d Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:29:39 +0000 Subject: [PATCH 01/60] noop audit module (#1994) --- apps/server/src/app.module.ts | 2 ++ apps/server/src/core/core.module.ts | 11 ----------- apps/server/src/integrations/audit/audit.module.ts | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/integrations/audit/audit.module.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index fc1d2c8b..6280ee09 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -25,6 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager'; import KeyvRedis from '@keyv/redis'; import { LoggerModule } from './common/logger/logger.module'; import { ClsModule } from 'nestjs-cls'; +import { NoopAuditModule } from './integrations/audit/audit.module'; const enterpriseModules = []; try { @@ -47,6 +48,7 @@ try { middleware: { mount: true }, }), LoggerModule, + NoopAuditModule, CoreModule, DatabaseModule, EnvironmentModule, diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index f336cf8c..81dfc138 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -20,10 +20,6 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd import { ShareModule } from './share/share.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; -import { - AUDIT_SERVICE, - NoopAuditService, -} from '../integrations/audit/audit.service'; import { ClsMiddleware } from 'nestjs-cls'; @Module({ @@ -43,13 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls'; NotificationModule, WatcherModule, ], - providers: [ - { - provide: AUDIT_SERVICE, - useClass: NoopAuditService, - }, - ], - exports: [AUDIT_SERVICE], }) export class CoreModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/apps/server/src/integrations/audit/audit.module.ts b/apps/server/src/integrations/audit/audit.module.ts new file mode 100644 index 00000000..eafffdd7 --- /dev/null +++ b/apps/server/src/integrations/audit/audit.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { AUDIT_SERVICE, NoopAuditService } from './audit.service'; + +@Global() +@Module({ + providers: [ + { + provide: AUDIT_SERVICE, + useClass: NoopAuditService, + }, + ], + exports: [AUDIT_SERVICE], +}) +export class NoopAuditModule {} From 7b69727a3041942d5dd4e8cd279281ad41884466 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:25:27 +0000 Subject: [PATCH 02/60] fix shared page mention view for non-logged in users (#2008) --- .../components/mention/mention-view.tsx | 79 +++++++++++++++---- .../components/subpages/subpages-view.tsx | 6 +- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/apps/client/src/features/editor/components/mention/mention-view.tsx b/apps/client/src/features/editor/components/mention/mention-view.tsx index a874cdf4..561f3e0f 100644 --- a/apps/client/src/features/editor/components/mention/mention-view.tsx +++ b/apps/client/src/features/editor/components/mention/mention-view.tsx @@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Text } from "@mantine/core"; import { IconFileDescription } from "@tabler/icons-react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; import { buildPageUrl, buildSharedPageUrl, @@ -13,17 +14,23 @@ import classes from "./mention.module.css"; export default function MentionView(props: NodeViewProps) { const { node } = props; const { label, entityType, entityId, slugId, anchorId } = node.attrs; + const isPageMention = entityType === "page"; const { spaceSlug, pageSlug } = useParams(); const { shareId } = useParams(); const navigate = useNavigate(); + + const location = useLocation(); + const isShareRoute = location.pathname.startsWith("/share"); + const { data: page, isLoading, isError, - } = usePageQuery({ pageId: entityType === "page" ? slugId : null }); + } = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null }); - const location = useLocation(); - const isShareRoute = location.pathname.startsWith("/share"); + const { data: sharedPage } = useSharePageQuery({ + pageId: isPageMention && isShareRoute ? slugId : undefined, + }); const currentPageSlugId = extractPageSlugId(pageSlug); const isSamePage = currentPageSlugId === slugId; @@ -39,10 +46,12 @@ export default function MentionView(props: NodeViewProps) { } }; + const sharePageTitle = sharedPage?.page?.title || label; + const shareSlugUrl = buildSharedPageUrl({ shareId, pageSlugId: slugId, - pageTitle: label, + pageTitle: sharePageTitle, anchorId, }); @@ -54,21 +63,59 @@ export default function MentionView(props: NodeViewProps) { )} - {entityType === "page" && isError && ( - - {label} - - )} - - {entityType === "page" && !isError && ( + {isPageMention && isShareRoute && ( + + + + + {sharePageTitle} + + + )} + + {isPageMention && !isShareRoute && isError && ( + + + + + + {label} + + + )} + + {isPageMention && !isShareRoute && !isError && ( + { // If we're in a shared context, use the shared subpages From 1fdee33206230431ce4a3030d97ebcd9385175fb Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:58:29 +0000 Subject: [PATCH 03/60] feat(editor): add auto-save and unsaved changes protection for diagrams (#2011) * feat(editor): add auto-save and unsaved changes protection for diagrams * 30 seconds --- .../editor/components/drawio/drawio-menu.tsx | 131 +++++++++++---- .../editor/components/drawio/drawio-view.tsx | 119 ++++++++++--- .../components/excalidraw/excalidraw-menu.tsx | 146 +++++++++++----- .../components/excalidraw/excalidraw-view.tsx | 157 +++++++++++++----- 4 files changed, 424 insertions(+), 129 deletions(-) diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index bdd1461b..8cda0348 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -1,6 +1,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; import { EditorMenuProps, @@ -9,6 +9,7 @@ import { import { ActionIcon, Modal, + Text, Tooltip, useComputedColorScheme, } from "@mantine/core"; @@ -29,10 +30,12 @@ import { DrawIoEmbed, DrawIoEmbedRef, EventExit, + EventExport, EventSave, } from "react-drawio"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { IAttachment } from "@/features/attachments/types/attachment.types"; +import { modals } from "@mantine/modals"; import classes from "../common/toolbar-menu.module.css"; export function DrawioMenu({ editor }: EditorMenuProps) { @@ -41,6 +44,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) { const [initialXML, setInitialXML] = useState(""); const drawioRef = useRef(null); const computedColorScheme = useComputedColorScheme(); + const isDirtyRef = useRef(false); + const isSavingRef = useRef(false); const editorState = useEditorState({ editor, @@ -131,33 +136,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) { editor.commands.deleteSelection(); }, [editor]); - const handleOpen = useCallback(async () => { - if (!editorState?.src) return; + const saveData = useCallback(async (svgXml: string) => { + if (isSavingRef.current) return; + + isSavingRef.current = true; try { - const url = getFileUrl(editorState.src); - const request = await fetch(url, { - credentials: "include", - cache: "no-store", - }); - const blob = await request.blob(); - - const reader = new FileReader(); - reader.readAsDataURL(blob); - reader.onloadend = () => { - const base64data = (reader.result || "") as string; - setInitialXML(base64data); - }; - } catch (err) { - console.error(err); - } finally { - open(); - } - }, [editorState?.src, open]); - - const handleSave = useCallback( - async (data: EventSave) => { - const svgString = decodeBase64ToSvgString(data.xml); + const svgString = decodeBase64ToSvgString(svgXml); const fileName = "diagram.drawio.svg"; const drawioSVGFile = await svgStringToFile(svgString, fileName); @@ -179,10 +164,85 @@ export function DrawioMenu({ editor }: EditorMenuProps) { attachmentId: attachment.id, }); + isDirtyRef.current = false; + } finally { + isSavingRef.current = false; + } + }, [editor, editorState?.attachmentId]); + + const handleClose = useCallback(() => { + if (!isDirtyRef.current) { close(); - }, - [editor, editorState?.attachmentId, close], - ); + return; + } + + modals.openConfirmModal({ + title: t("Unsaved changes"), + children: ( + + {t("You have unsaved changes that will be lost.")} + + ), + centered: true, + labels: { confirm: t("Discard"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => { + isDirtyRef.current = false; + close(); + }, + }); + }, [close, t]); + + const handleOpen = useCallback(async () => { + if (!editorState?.src) return; + + try { + const url = getFileUrl(editorState.src); + const request = await fetch(url, { + credentials: "include", + cache: "no-store", + }); + const blob = await request.blob(); + + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => { + const base64data = (reader.result || "") as string; + setInitialXML(base64data); + }; + } catch (err) { + console.error(err); + } finally { + isDirtyRef.current = false; + open(); + } + }, [editorState?.src, open]); + + useEffect(() => { + if (!opened) return; + + const interval = setInterval(() => { + if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) { + drawioRef.current.exportDiagram({ format: "xmlsvg" }); + } + }, 60_000); + + return () => clearInterval(interval); + }, [opened]); + + useEffect(() => { + if (!opened) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [opened, handleClose]); return ( <> @@ -276,7 +336,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) { - + @@ -285,6 +345,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) { ref={drawioRef} xml={initialXML} baseUrl={getDrawioUrl()} + autosave urlParameters={{ ui: computedColorScheme === "light" ? "kennedy" : "dark", spin: true, @@ -296,13 +357,19 @@ export function DrawioMenu({ editor }: EditorMenuProps) { if (data.parentEvent !== "save") { return; } - handleSave(data); + saveData(data.xml).then(() => close()).catch(() => {}); }} onClose={(data: EventExit) => { if (data.parentEvent) { return; } - close(); + handleClose(); + }} + onAutoSave={() => { + isDirtyRef.current = true; + }} + onExport={(data: EventExport) => { + saveData(data.data).catch(() => {}); }} /> diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index 0b1580ec..1b1ad95c 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -6,7 +6,7 @@ import { Text, useComputedColorScheme, } from "@mantine/core"; -import { useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { useDisclosure } from "@mantine/hooks"; import { getDrawioUrl } from "@/lib/config.ts"; @@ -14,6 +14,7 @@ import { DrawIoEmbed, DrawIoEmbedRef, EventExit, + EventExport, EventSave, } from "react-drawio"; import { IAttachment } from "@/features/attachments/types/attachment.types"; @@ -21,6 +22,7 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import clsx from "clsx"; import { IconEdit } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { modals } from "@mantine/modals"; export default function DrawioView(props: NodeViewProps) { const { t } = useTranslation(); @@ -30,42 +32,108 @@ export default function DrawioView(props: NodeViewProps) { const [initialXML, setInitialXML] = useState(""); const [opened, { open, close }] = useDisclosure(false); const computedColorScheme = useComputedColorScheme(); + const isDirtyRef = useRef(false); + const isSavingRef = useRef(false); const handleOpen = async () => { if (!editor.isEditable) { return; } + isDirtyRef.current = false; open(); }; - const handleSave = async (data: EventSave) => { - const svgString = decodeBase64ToSvgString(data.xml); - const fileName = "diagram.drawio.svg"; - const drawioSVGFile = await svgStringToFile(svgString, fileName); + const saveData = async (svgXml: string, updateSrc = true) => { + if (isSavingRef.current) return; - //@ts-ignore - const pageId = editor.storage?.pageId; + isSavingRef.current = true; - let attachment: IAttachment = null; - if (attachmentId) { - attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); - } else { - attachment = await uploadFile(drawioSVGFile, pageId); + try { + const svgString = decodeBase64ToSvgString(svgXml); + const fileName = "diagram.drawio.svg"; + const drawioSVGFile = await svgStringToFile(svgString, fileName); + + //@ts-ignore + const pageId = editor.storage?.pageId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); + } else { + attachment = await uploadFile(drawioSVGFile, pageId); + } + + if (updateSrc) { + updateAttributes({ + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + } else { + updateAttributes({ + attachmentId: attachment.id, + }); + } + + isDirtyRef.current = false; + } finally { + isSavingRef.current = false; + } + }; + + const handleClose = useCallback(() => { + if (!isDirtyRef.current) { + close(); + return; } - updateAttributes({ - src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, - title: attachment.fileName, - size: attachment.fileSize, - attachmentId: attachment.id, + modals.openConfirmModal({ + title: t("Unsaved changes"), + children: ( + + {t("You have unsaved changes that will be lost.")} + + ), + centered: true, + labels: { confirm: t("Discard"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => { + isDirtyRef.current = false; + close(); + }, }); + }, [close, t]); - close(); - }; + useEffect(() => { + if (!opened) return; + + const interval = setInterval(() => { + if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) { + drawioRef.current.exportDiagram({ format: "xmlsvg" }); + } + }, 30_000); + + return () => clearInterval(interval); + }, [opened]); + + useEffect(() => { + if (!opened) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [opened, handleClose]); return ( - + @@ -74,6 +142,7 @@ export default function DrawioView(props: NodeViewProps) { ref={drawioRef} xml={initialXML} baseUrl={getDrawioUrl()} + autosave urlParameters={{ ui: computedColorScheme === "light" ? "kennedy" : "dark", spin: true, @@ -85,13 +154,19 @@ export default function DrawioView(props: NodeViewProps) { if (data.parentEvent !== "save") { return; } - handleSave(data); + saveData(data.xml, true).then(() => close()).catch(() => {}); }} onClose={(data: EventExit) => { if (data.parentEvent) { return; } - close(); + handleClose(); + }} + onAutoSave={() => { + isDirtyRef.current = true; + }} + onExport={(data: EventExport) => { + saveData(data.data, false).catch(() => {}); }} /> diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx index 6e5852a3..c9ae7c08 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx @@ -1,6 +1,6 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import { lazy, Suspense, useCallback, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; import { EditorMenuProps, @@ -10,9 +10,11 @@ import { ActionIcon, Button, Group, + Text, Tooltip, useComputedColorScheme, } from "@mantine/core"; +import { modals } from "@mantine/modals"; import { useDisclosure } from "@mantine/hooks"; import clsx from "clsx"; import { @@ -52,6 +54,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { }); const [excalidrawData, setExcalidrawData] = useState(null); const computedColorScheme = useComputedColorScheme(); + const isDirtyRef = useRef(false); + const isSavingRef = useRef(false); + const isInitialLoadRef = useRef(true); + const lastFingerprintRef = useRef(""); const editorState = useEditorState({ editor, @@ -160,57 +166,109 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { } catch (err) { console.error(err); } finally { + isDirtyRef.current = false; + isInitialLoadRef.current = true; open(); } }, [editorState?.src, open]); - const handleSave = useCallback(async () => { - if (!excalidrawAPI) { + const saveData = useCallback(async () => { + if (!excalidrawAPI || isSavingRef.current) { return; } - const { exportToSvg } = await import("@excalidraw/excalidraw"); + isSavingRef.current = true; - const svg = await exportToSvg({ - elements: excalidrawAPI?.getSceneElements(), - appState: { - exportEmbedScene: true, - exportWithDarkMode: false, - }, - files: excalidrawAPI?.getFiles(), - }); + try { + const { exportToSvg } = await import("@excalidraw/excalidraw"); - const serializer = new XMLSerializer(); - let svgString = serializer.serializeToString(svg); + const svg = await exportToSvg({ + elements: excalidrawAPI?.getSceneElements(), + appState: { + exportEmbedScene: true, + exportWithDarkMode: false, + }, + files: excalidrawAPI?.getFiles(), + }); - svgString = svgString.replace( - /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, - "https://unpkg.com/@excalidraw/excalidraw@latest", - ); + const serializer = new XMLSerializer(); + let svgString = serializer.serializeToString(svg); - const fileName = "diagram.excalidraw.svg"; - const excalidrawSvgFile = await svgStringToFile(svgString, fileName); + svgString = svgString.replace( + /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, + "https://unpkg.com/@excalidraw/excalidraw@latest", + ); - // @ts-ignore - const pageId = editor.storage?.pageId; - const attachmentId = editorState?.attachmentId; + const fileName = "diagram.excalidraw.svg"; + const excalidrawSvgFile = await svgStringToFile(svgString, fileName); - let attachment: IAttachment = null; - if (attachmentId) { - attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); - } else { - attachment = await uploadFile(excalidrawSvgFile, pageId); + // @ts-ignore + const pageId = editor.storage?.pageId; + const attachmentId = editorState?.attachmentId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); + } else { + attachment = await uploadFile(excalidrawSvgFile, pageId); + } + + editor.commands.updateAttributes("excalidraw", { + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + + isDirtyRef.current = false; + } finally { + isSavingRef.current = false; + } + }, [editor, excalidrawAPI, editorState?.attachmentId]); + + const handleSaveAndExit = useCallback(async () => { + try { + await saveData(); + close(); + } catch { + // save failed, modal stays open + } + }, [saveData, close]); + + const handleClose = useCallback(() => { + if (!isDirtyRef.current) { + close(); + return; } - editor.commands.updateAttributes("excalidraw", { - src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, - title: attachment.fileName, - size: attachment.fileSize, - attachmentId: attachment.id, + modals.openConfirmModal({ + title: t("Unsaved changes"), + children: ( + + {t("You have unsaved changes that will be lost.")} + + ), + centered: true, + labels: { confirm: t("Discard"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => { + isDirtyRef.current = false; + close(); + }, }); + }, [close, t]); - close(); - }, [editor, excalidrawAPI, editorState?.attachmentId, close]); + useEffect(() => { + if (!opened) return; + + const interval = setInterval(() => { + if (isDirtyRef.current && !isSavingRef.current) { + saveData().catch(() => {}); + } + }, 60_000); + + return () => clearInterval(interval); + }, [opened, saveData]); return ( <> @@ -317,7 +375,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { zIndex: 200, }} isOpen={opened} - onRequestClose={close} + onRequestClose={handleClose} disableCloseOnBgClick={true} contentProps={{ style: { @@ -332,10 +390,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { bg="var(--mantine-color-body)" p="xs" > - - @@ -343,6 +401,18 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { setExcalidrawAPI(api)} + onChange={(elements, _appState, files) => { + const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`; + if (isInitialLoadRef.current) { + lastFingerprintRef.current = fingerprint; + isInitialLoadRef.current = false; + return; + } + if (fingerprint !== lastFingerprintRef.current) { + lastFingerprintRef.current = fingerprint; + isDirtyRef.current = true; + } + }} initialData={{ ...excalidrawData, scrollToContent: true, diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 51ff5b06..0673f853 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -7,7 +7,14 @@ import { Text, useComputedColorScheme, } from "@mantine/core"; -import { lazy, Suspense, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { svgStringToFile } from "@/lib"; import { useDisclosure } from "@mantine/hooks"; @@ -20,6 +27,7 @@ import { IconEdit } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useHandleLibrary } from "@excalidraw/excalidraw"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; +import { modals } from "@mantine/modals"; const ExcalidrawComponent = lazy(() => import("@excalidraw/excalidraw").then((module) => ({ @@ -42,59 +50,122 @@ export default function ExcalidrawView(props: NodeViewProps) { const [opened, { open, close }] = useDisclosure(false); const computedColorScheme = useComputedColorScheme(); + const isDirtyRef = useRef(false); + const isSavingRef = useRef(false); + const isInitialLoadRef = useRef(true); + const lastFingerprintRef = useRef(""); + const handleOpen = async () => { if (!editor.isEditable) { return; } + isDirtyRef.current = false; + isInitialLoadRef.current = true; open(); }; - const handleSave = async () => { - if (!excalidrawAPI) { + const saveData = useCallback(async (updateSrc = true) => { + if (!excalidrawAPI || isSavingRef.current) { return; } - const { exportToSvg } = await import("@excalidraw/excalidraw"); + isSavingRef.current = true; - const svg = await exportToSvg({ - elements: excalidrawAPI?.getSceneElements(), - appState: { - exportEmbedScene: true, - exportWithDarkMode: false, - }, - files: excalidrawAPI?.getFiles(), - }); + try { + const { exportToSvg } = await import("@excalidraw/excalidraw"); - const serializer = new XMLSerializer(); - let svgString = serializer.serializeToString(svg); + const svg = await exportToSvg({ + elements: excalidrawAPI?.getSceneElements(), + appState: { + exportEmbedScene: true, + exportWithDarkMode: false, + }, + files: excalidrawAPI?.getFiles(), + }); - svgString = svgString.replace( - /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, - "https://unpkg.com/@excalidraw/excalidraw@latest", - ); + const serializer = new XMLSerializer(); + let svgString = serializer.serializeToString(svg); - const fileName = "diagram.excalidraw.svg"; - const excalidrawSvgFile = await svgStringToFile(svgString, fileName); + svgString = svgString.replace( + /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, + "https://unpkg.com/@excalidraw/excalidraw@latest", + ); - // @ts-ignore - const pageId = editor.storage?.pageId; + const fileName = "diagram.excalidraw.svg"; + const excalidrawSvgFile = await svgStringToFile(svgString, fileName); - let attachment: IAttachment = null; - if (attachmentId) { - attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); - } else { - attachment = await uploadFile(excalidrawSvgFile, pageId); + // @ts-ignore + const pageId = editor.storage?.pageId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); + } else { + attachment = await uploadFile(excalidrawSvgFile, pageId); + } + + if (updateSrc) { + updateAttributes({ + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + } else { + updateAttributes({ + attachmentId: attachment.id, + }); + } + + isDirtyRef.current = false; + } finally { + isSavingRef.current = false; + } + }, [excalidrawAPI, editor, attachmentId, updateAttributes]); + + const handleSaveAndExit = useCallback(async () => { + try { + await saveData(); + close(); + } catch { + /* empty */ + } + }, [saveData, close]); + + const handleClose = useCallback(() => { + if (!isDirtyRef.current) { + close(); + return; } - updateAttributes({ - src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, - title: attachment.fileName, - size: attachment.fileSize, - attachmentId: attachment.id, + modals.openConfirmModal({ + title: t("Unsaved changes"), + children: ( + + {t("You have unsaved changes that will be lost.")} + + ), + centered: true, + labels: { confirm: t("Discard"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: () => { + isDirtyRef.current = false; + close(); + }, }); + }, [close, t]); - close(); - }; + useEffect(() => { + if (!opened) return; + + const interval = setInterval(() => { + if (isDirtyRef.current && !isSavingRef.current) { + saveData(false).catch(() => {}); + } + }, 30_000); + + return () => clearInterval(interval); + }, [opened, saveData]); return ( @@ -105,7 +176,7 @@ export default function ExcalidrawView(props: NodeViewProps) { zIndex: 200, }} isOpen={opened} - onRequestClose={close} + onRequestClose={handleClose} disableCloseOnBgClick={true} contentProps={{ style: { @@ -120,10 +191,10 @@ export default function ExcalidrawView(props: NodeViewProps) { bg="var(--mantine-color-body)" p="xs" > - - @@ -131,6 +202,18 @@ export default function ExcalidrawView(props: NodeViewProps) { setExcalidrawAPI(api)} + onChange={(elements, _appState, files) => { + const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`; + if (isInitialLoadRef.current) { + lastFingerprintRef.current = fingerprint; + isInitialLoadRef.current = false; + return; + } + if (fingerprint !== lastFingerprintRef.current) { + lastFingerprintRef.current = fingerprint; + isDirtyRef.current = true; + } + }} initialData={{ ...excalidrawData, scrollToContent: true, From 65b89a1b24e242497f89919d9b6f5cb862b48564 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:40:32 +0000 Subject: [PATCH 04/60] fix email button (#2017) --- apps/server/src/ee | 2 +- .../emails/comment-created-email.tsx | 20 ++------- .../emails/comment-mention-email.tsx | 20 ++------- .../emails/comment-resolved-email.tsx | 20 ++------- .../transactional/emails/invitation-email.tsx | 20 ++------- .../emails/page-mention-email.tsx | 20 ++------- .../emails/permission-granted-email.tsx | 20 ++------- .../transactional/partials/partials.tsx | 43 ++++++++++++++++++- 8 files changed, 67 insertions(+), 98 deletions(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index 8b7ae8cf..62a8a7e5 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 8b7ae8cf1b17e6c5f7b497d35df33b058cf0472d +Subproject commit 62a8a7e548a17b8e3baf4dfdc90f2f432a691ee0 diff --git a/apps/server/src/integrations/transactional/emails/comment-created-email.tsx b/apps/server/src/integrations/transactional/emails/comment-created-email.tsx index 49bde6c5..7fee6dc9 100644 --- a/apps/server/src/integrations/transactional/emails/comment-created-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-created-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; @@ -23,19 +23,7 @@ export const CommentCreateEmail = ({ {pageTitle}. -
- -
+ View ); }; diff --git a/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx b/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx index 379a8586..079912a0 100644 --- a/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-mention-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; @@ -23,19 +23,7 @@ export const CommentMentionEmail = ({ {pageTitle}. -
- -
+ View ); }; diff --git a/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx b/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx index 886d08fd..4555acb6 100644 --- a/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx +++ b/apps/server/src/integrations/transactional/emails/comment-resolved-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; @@ -23,19 +23,7 @@ export const CommentResolvedEmail = ({ {pageTitle}. -
- -
+ View ); }; diff --git a/apps/server/src/integrations/transactional/emails/invitation-email.tsx b/apps/server/src/integrations/transactional/emails/invitation-email.tsx index b564b2c1..ac8ce32a 100644 --- a/apps/server/src/integrations/transactional/emails/invitation-email.tsx +++ b/apps/server/src/integrations/transactional/emails/invitation-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { inviteLink: string; @@ -17,19 +17,7 @@ export const InvitationEmail = ({ inviteLink }: Props) => { Please click the button below to accept this invitation. -
- -
+ Accept Invite ); }; diff --git a/apps/server/src/integrations/transactional/emails/page-mention-email.tsx b/apps/server/src/integrations/transactional/emails/page-mention-email.tsx index a4b9baa7..d7886641 100644 --- a/apps/server/src/integrations/transactional/emails/page-mention-email.tsx +++ b/apps/server/src/integrations/transactional/emails/page-mention-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; @@ -19,19 +19,7 @@ export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => { {pageTitle}. -
- -
+ View ); }; diff --git a/apps/server/src/integrations/transactional/emails/permission-granted-email.tsx b/apps/server/src/integrations/transactional/emails/permission-granted-email.tsx index f4aa878a..bd4529b7 100644 --- a/apps/server/src/integrations/transactional/emails/permission-granted-email.tsx +++ b/apps/server/src/integrations/transactional/emails/permission-granted-email.tsx @@ -1,7 +1,7 @@ -import { Section, Text, Button } from '@react-email/components'; +import { Section, Text } from '@react-email/components'; import * as React from 'react'; -import { button, content, paragraph } from '../css/styles'; -import { MailBody } from '../partials/partials'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; interface Props { actorName: string; @@ -25,19 +25,7 @@ export const PermissionGrantedEmail = ({ {pageTitle}. -
- -
+ View ); }; diff --git a/apps/server/src/integrations/transactional/partials/partials.tsx b/apps/server/src/integrations/transactional/partials/partials.tsx index 753a978f..f97eb989 100644 --- a/apps/server/src/integrations/transactional/partials/partials.tsx +++ b/apps/server/src/integrations/transactional/partials/partials.tsx @@ -1,4 +1,4 @@ -import { container, footer, h1, logo, main } from '../css/styles'; +import { button as buttonStyle, container, footer, h1, logo, main } from '../css/styles'; import { Body, Container, @@ -35,6 +35,47 @@ export function MailHeader() { ); } +interface EmailButtonProps { + href: string; + children: React.ReactNode; +} + +export function EmailButton({ href, children }: EmailButtonProps) { + return ( + + + + +
+ + {children} + +
+ ); +} + export function MailFooter() { return (
From d0ed6865cb7e9c12a84c1ce7eb86e07a3e8981b4 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 14 Mar 2026 01:01:24 +0000 Subject: [PATCH 05/60] fix page level comment on mobile (#2018) * add icon next to comment box --- .../comment/components/comment-actions.tsx | 7 ++++- .../components/comment-list-with-tabs.tsx | 29 ++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/client/src/features/comment/components/comment-actions.tsx b/apps/client/src/features/comment/components/comment-actions.tsx index 882c6f74..c0792bc8 100644 --- a/apps/client/src/features/comment/components/comment-actions.tsx +++ b/apps/client/src/features/comment/components/comment-actions.tsx @@ -24,7 +24,12 @@ function CommentActions({ )} - diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index 82dd4494..022ea2fa 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -27,6 +27,9 @@ import { extractPageSlugId } from "@/lib"; import { useTranslation } from "react-i18next"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; +import { useAtom } from "jotai"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; function CommentListWithTabs() { const { t } = useTranslation(); @@ -345,6 +348,7 @@ const PageCommentInput = ({ onSave, isLoading }) => { const [content, setContent] = useState(""); const { ref, focused } = useFocusWithin(); const commentEditorRef = useRef(null); + const [currentUser] = useAtom(currentUserAtom); const handleSave = useCallback(() => { onSave(null, content); @@ -363,19 +367,30 @@ const PageCommentInput = ({ onSave, isLoading }) => { position: "relative", }} > - + + +
+ +
+
{focused && ( e.preventDefault()} loading={isLoading} style={{ position: "absolute", right: 8, bottom: 30 }} > From 97c459be670562b79869463bb7177341491ed7a5 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:36:30 +0000 Subject: [PATCH 06/60] feat(cloud): add find-workspace and email verification endpoints (#2020) * feat: add find-workspace and email verification endpoints * sync --- .../public/locales/en-US/translation.json | 13 +- apps/client/src/App.tsx | 2 + .../src/ee/cloud/service/cloud-service.ts | 12 ++ .../src/ee/components/cloud-login-form.tsx | 59 ++++++++ apps/client/src/ee/pages/verify-email.tsx | 107 +++++++++++++ .../auth/components/setup-workspace-form.tsx | 8 +- .../src/features/auth/hooks/use-auth.ts | 26 +++- .../features/auth/services/auth-service.ts | 3 +- .../workspace/services/workspace-service.ts | 2 +- apps/client/src/lib/app-route.ts | 1 + apps/server/package.json | 1 + .../{validator => validators}/is-iso6391.ts | 0 .../validators/no-urls.validator.spec.ts | 142 ++++++++++++++++++ .../common/validators/no-urls.validator.ts | 42 ++++++ apps/server/src/core/auth/auth.constants.ts | 1 + apps/server/src/core/auth/auth.util.ts | 32 ++++ .../core/auth/dto/create-admin-user.dto.ts | 2 + .../src/core/auth/dto/create-user.dto.ts | 2 + .../src/core/auth/services/auth.service.ts | 19 +++ .../src/core/workspace/dto/invitation.dto.ts | 2 + .../workspace/services/workspace.service.ts | 2 +- apps/server/src/ee | 2 +- .../environment/environment.validation.ts | 2 +- apps/server/src/main.ts | 1 + pnpm-lock.yaml | 9 ++ 25 files changed, 479 insertions(+), 13 deletions(-) create mode 100644 apps/client/src/ee/pages/verify-email.tsx rename apps/server/src/common/{validator => validators}/is-iso6391.ts (100%) create mode 100644 apps/server/src/common/validators/no-urls.validator.spec.ts create mode 100644 apps/server/src/common/validators/no-urls.validator.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index cd2b7559..da0f0b81 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -693,5 +693,16 @@ "Failed to update trash retention": "Failed to update trash retention", "Removed page restriction": "Removed page restriction", "Added page permission": "Added page permission", - "Removed page permission": "Removed page permission" + "Removed page permission": "Removed page permission", + "Verifying your email": "Verifying your email", + "Please wait...": "Please wait...", + "Verification failed. The link may have expired.": "Verification failed. The link may have expired.", + "Check your email": "Check your email", + "We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.", + "We sent a verification link to your email.": "We sent a verification link to your email.", + "Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.", + "Resend verification email": "Resend verification email", + "Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.", + "Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.", + "We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces." } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c290157c..b99e63b3 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; +import VerifyEmail from "@/ee/pages/verify-email.tsx"; export default function App() { const { t } = useTranslation(); @@ -63,6 +64,7 @@ export default function App() { <> } /> } /> + } /> )} diff --git a/apps/client/src/ee/cloud/service/cloud-service.ts b/apps/client/src/ee/cloud/service/cloud-service.ts index e544733e..5411b802 100644 --- a/apps/client/src/ee/cloud/service/cloud-service.ts +++ b/apps/client/src/ee/cloud/service/cloud-service.ts @@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise> { const req = await api.post>("/workspace/joined"); return req.data; } + +export async function findWorkspacesByEmail(email: string): Promise { + await api.post("/workspace/find-by-email", { email }); +} + +export async function verifyEmail(data: { token: string }): Promise { + await api.post("/workspace/verify-email", data); +} + +export async function resendVerificationEmail(data: { email: string; sig: string }): Promise { + await api.post("/workspace/resend-verification", data); +} diff --git a/apps/client/src/ee/components/cloud-login-form.tsx b/apps/client/src/ee/components/cloud-login-form.tsx index 01ddd031..6ab7ebd9 100644 --- a/apps/client/src/ee/components/cloud-login-form.tsx +++ b/apps/client/src/ee/components/cloud-login-form.tsx @@ -20,14 +20,21 @@ import APP_ROUTE from "@/lib/app-route.ts"; import { useTranslation } from "react-i18next"; import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx"; import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts"; +import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts"; const formSchema = z.object({ hostname: z.string().min(1, { message: "subdomain is required" }), }); +const findWorkspaceSchema = z.object({ + email: z.string().email({ message: "Please enter a valid email" }), +}); + export function CloudLoginForm() { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); + const [isFindLoading, setIsFindLoading] = useState(false); + const [findEmailSent, setFindEmailSent] = useState(false); const { data: joinedWorkspaces } = useJoinedWorkspacesQuery(); const form = useForm({ @@ -37,6 +44,13 @@ export function CloudLoginForm() { }, }); + const findForm = useForm({ + validate: zod4Resolver(findWorkspaceSchema), + initialValues: { + email: "", + }, + }); + async function onSubmit(data: { hostname: string }) { setIsLoading(true); @@ -54,6 +68,19 @@ export function CloudLoginForm() { setIsLoading(false); } + async function onFindSubmit(data: { email: string }) { + setIsFindLoading(true); + + try { + await findWorkspacesByEmail(data.email); + setFindEmailSent(true); + } catch { + findForm.setFieldError("email", "An error occurred. Please try again."); + } + + setIsFindLoading(false); + } + return (
@@ -83,6 +110,38 @@ export function CloudLoginForm() { {t("Continue")} + + + + {findEmailSent ? ( + + {t("We've sent you an email with your associated workspaces.")} + + ) : ( +
+ + {t("Find your workspaces")} + + + + + )}
diff --git a/apps/client/src/ee/pages/verify-email.tsx b/apps/client/src/ee/pages/verify-email.tsx new file mode 100644 index 00000000..623c3202 --- /dev/null +++ b/apps/client/src/ee/pages/verify-email.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { Container, Title, Text, Button, Box } from "@mantine/core"; +import classes from "../../features/auth/components/auth.module.css"; +import { + verifyEmail, + resendVerificationEmail, +} from "@/ee/cloud/service/cloud-service.ts"; +import { notifications } from "@mantine/notifications"; +import APP_ROUTE from "@/lib/app-route.ts"; +import { useTranslation } from "react-i18next"; + +export default function VerifyEmail() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get("token"); + const rawEmail = searchParams.get("email"); + const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null; + const sig = searchParams.get("sig"); + const [isResending, setIsResending] = useState(false); + const [resent, setResent] = useState(false); + + useEffect(() => { + if (token) { + handleVerify(token); + } + }, [token]); + + async function handleVerify(verifyToken: string) { + try { + await verifyEmail({ token: verifyToken }); + navigate(APP_ROUTE.HOME); + } catch (err) { + notifications.show({ + message: t("Verification failed. The link may have expired."), + color: "red", + }); + navigate(APP_ROUTE.AUTH.LOGIN); + } + } + + async function handleResend() { + if (!email || !sig) return; + setIsResending(true); + + try { + await resendVerificationEmail({ email, sig }); + setResent(true); + } catch { + notifications.show({ + message: t("Failed to resend verification email. Please try again."), + color: "red", + }); + } + + setIsResending(false); + } + + if (token) { + return ( + + + + {t("Verifying your email")} + + + {t("Please wait...")} + + + + ); + } + + return ( + + + + {t("Check your email")} + + + {email + ? t("We sent a verification link to {{email}}.", { email }) + : t("We sent a verification link to your email.")} + + + {t("Click the link to verify your email and access your workspace.")} + + {email && sig && !resent && ( + + )} + {resent && ( + + {t("Verification email sent. Please check your inbox.")} + + )} + + + ); +} diff --git a/apps/client/src/features/auth/components/setup-workspace-form.tsx b/apps/client/src/features/auth/components/setup-workspace-form.tsx index 261412a9..6eaf3d12 100644 --- a/apps/client/src/features/auth/components/setup-workspace-form.tsx +++ b/apps/client/src/features/auth/components/setup-workspace-form.tsx @@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts"; const formSchema = z.object({ workspaceName: z.string().trim().max(50).optional(), - name: z.string().min(1).max(50), + name: z.string().min(1, { message: "Name is required" }).max(50), email: z - .email() - .min(1, { message: "email is required" }), - password: z.string().min(8), + .email({ message: "Invalid email address" }) + .min(1, { message: "Email is required" }), + password: z.string().min(8, { message: "Password must be at least 8 characters" }), }); type FormValues = z.infer; diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 6e1b4e34..411e04b4 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { RESET } from "jotai/utils"; import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; -import { exchangeTokenRedirectUrl } from "@/ee/utils.ts"; +import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts"; export default function useAuth() { const { t } = useTranslation(); @@ -52,9 +52,18 @@ export default function useAuth() { } } catch (err) { setIsLoading(false); - console.log(err); + + const message = err.response?.data?.message; + if (isCloud() && message?.includes("verify your email")) { + const sig = err.response?.data?.emailSignature; + navigate( + `${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`, + ); + return; + } + notifications.show({ - message: err.response?.data.message, + message, color: "red", }); } @@ -92,6 +101,17 @@ export default function useAuth() { try { if (isCloud()) { const res = await createWorkspace(data); + + if (res?.requiresEmailVerification) { + const hostname = res?.workspace?.hostname; + if (hostname) { + window.location.href = + getHostnameUrl(hostname) + + `/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`; + } + return; + } + const hostname = res?.workspace?.hostname; const exchangeToken = res?.exchangeToken; if (hostname && exchangeToken) { diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index 20e437f3..20552d56 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -50,4 +50,5 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise { export async function getCollabToken(): Promise { const req = await api.post("/auth/collab-token"); return req.data; -} \ No newline at end of file +} + diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 20da1146..0ffd6f23 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -113,7 +113,7 @@ export async function getInvitationById(data: { export async function createWorkspace( data: ISetupWorkspace, -): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> { +): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> { const req = await api.post("/workspace/create", data); return req.data; } diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index c4a13093..630dd048 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -12,6 +12,7 @@ const APP_ROUTE = { SELECT_WORKSPACE: "/select", MFA_CHALLENGE: "/login/mfa", MFA_SETUP_REQUIRED: "/login/mfa/setup", + VERIFY_EMAIL: "/verify-email", }, SETTINGS: { ACCOUNT: { diff --git a/apps/server/package.json b/apps/server/package.json index 5cffe556..083f8a07 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -107,6 +107,7 @@ "sanitize-filename-ts": "1.0.2", "socket.io": "^4.8.3", "stripe": "^17.5.0", + "tlds": "^1.261.0", "tmp-promise": "^3.0.3", "tseep": "^1.3.1", "typesense": "^2.1.0", diff --git a/apps/server/src/common/validator/is-iso6391.ts b/apps/server/src/common/validators/is-iso6391.ts similarity index 100% rename from apps/server/src/common/validator/is-iso6391.ts rename to apps/server/src/common/validators/is-iso6391.ts diff --git a/apps/server/src/common/validators/no-urls.validator.spec.ts b/apps/server/src/common/validators/no-urls.validator.spec.ts new file mode 100644 index 00000000..07ad5721 --- /dev/null +++ b/apps/server/src/common/validators/no-urls.validator.spec.ts @@ -0,0 +1,142 @@ +import { containsDomain } from './no-urls.validator'; + +// containsDomain returns true if value contains a domain-like pattern +// The full NoUrls validator also checks for https:// URLs separately + +describe('containsDomain', () => { + describe('bare domains with real TLDs — should block', () => { + it.each([ + 'example.com', + 'example.net', + 'example.org', + 'example.io', + 'example.co', + 'example.dev', + 'example.app', + 'example.me', + 'example.info', + 'example.tech', + 'example.aero', + 'example.cloud', + 'example.museum', + 'example.abc', + 'example.uk', + 'example.de', + 'example.fr', + 'example.ru', + ])('blocks "%s"', (value) => { + expect(containsDomain(value)).toBe(true); + }); + }); + + describe('domains with paths — should block', () => { + it.each([ + 'example.com/reset', + 'example.com/reset-password', + 'click example.com/page', + 'go to example.net/login', + ])('blocks "%s"', (value) => { + expect(containsDomain(value)).toBe(true); + }); + }); + + describe('multi-part domains — should block', () => { + it.each([ + 'Foo.com.net', + 'Foo.com.', + 'Foo.mine.net', + 'Foo.mine.ne', + 'sub.example.com', + 'login.example.co.uk', + ])('blocks "%s"', (value) => { + expect(containsDomain(value)).toBe(true); + }); + }); + + describe('domain in sentence — should block', () => { + it.each([ + 'Reset your password at example.com', + 'URGENT click example.com/reset', + 'Visit example.org for details', + 'go to mysite.io now', + ])('blocks "%s"', (value) => { + expect(containsDomain(value)).toBe(true); + }); + }); + + describe('case insensitive — should block', () => { + it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => { + expect(containsDomain(value)).toBe(true); + }); + }); + + describe('fake TLDs — should allow', () => { + it.each([ + 'Foo.mine', + 'Foo.blarg', + 'Foo.qqq', + 'Foo.zz', + 'Foo.abcd', + 'Foo.abcde', + 'Foo.abcdef', + 'Foo.abcdefg', + ])('allows "%s"', (value) => { + expect(containsDomain(value)).toBe(false); + }); + }); + + describe('too short suffix — should allow', () => { + it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => { + expect(containsDomain(value)).toBe(false); + }); + }); + + describe('multi-part with fake TLD — should allow', () => { + it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => { + expect(containsDomain(value)).toBe(false); + }); + }); + + describe('emails — should allow', () => { + it.each([ + 'user@example.com', + 'admin@company.org', + 'test@sub.domain.co.uk', + ])('allows "%s"', (value) => { + expect(containsDomain(value)).toBe(false); + }); + }); + + describe('normal names — should allow', () => { + it.each([ + 'John Smith', + 'Dr. Smith', + 'A. B. Charlie', + 'John', + 'Mary Jane', + "O'Brien", + 'Jean-Pierre', + 'José García', + ])('allows "%s"', (value) => { + expect(containsDomain(value)).toBe(false); + }); + }); + + describe('IP addresses — should allow', () => { + it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])( + 'allows "%s"', + (value) => { + expect(containsDomain(value)).toBe(false); + }, + ); + }); + + describe('edge cases — should allow', () => { + it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])( + 'allows "%s"', + (value) => { + expect(containsDomain(value)).toBe(false); + }, + ); + }); +}); diff --git a/apps/server/src/common/validators/no-urls.validator.ts b/apps/server/src/common/validators/no-urls.validator.ts new file mode 100644 index 00000000..c26faecc --- /dev/null +++ b/apps/server/src/common/validators/no-urls.validator.ts @@ -0,0 +1,42 @@ +import { registerDecorator, ValidationOptions } from 'class-validator'; +import * as tlds from 'tlds'; + +const URL_PATTERN = /https?:\/\//i; +const tldSet = new Set(tlds.map((t) => t.toLowerCase())); + +export function containsDomain(value: string): boolean { + const tokens = value.split(/\s+/); + for (const token of tokens) { + if (token.includes('@')) continue; + const segments = token.split('.'); + for (let i = 1; i < segments.length; i++) { + const suffix = segments[i].replace(/[^\w].*/g, ''); + if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) { + return true; + } + } + } + return false; +} + +export function NoUrls(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'noUrls', + target: object.constructor, + propertyName, + options: { + message: 'Must not contain URLs or domain names', + ...validationOptions, + }, + validator: { + validate(value: unknown) { + if (typeof value !== 'string') return true; + if (URL_PATTERN.test(value)) return false; + if (containsDomain(value)) return false; + return true; + }, + }, + }); + }; +} diff --git a/apps/server/src/core/auth/auth.constants.ts b/apps/server/src/core/auth/auth.constants.ts index 555149c4..fda2346e 100644 --- a/apps/server/src/core/auth/auth.constants.ts +++ b/apps/server/src/core/auth/auth.constants.ts @@ -1,3 +1,4 @@ export enum UserTokenType { FORGOT_PASSWORD = 'forgot-password', + EMAIL_VERIFICATION = 'email-verification', } diff --git a/apps/server/src/core/auth/auth.util.ts b/apps/server/src/core/auth/auth.util.ts index cdd46e9b..e2de9bf2 100644 --- a/apps/server/src/core/auth/auth.util.ts +++ b/apps/server/src/core/auth/auth.util.ts @@ -1,5 +1,37 @@ import { BadRequestException } from '@nestjs/common'; import { Workspace } from '@docmost/db/types/entity.types'; +import { createHmac } from 'node:crypto'; + +export function computeEmailSignature( + email: string, + workspaceId: string, + appSecret: string, +): string { + return createHmac('sha256', appSecret) + .update(`${email.toLowerCase()}:${workspaceId}`) + .digest('hex'); +} + +export function throwIfEmailNotVerified(opts: { + isCloud: boolean; + emailVerifiedAt: Date | null; + email: string; + workspaceId: string; + appSecret: string; +}): void { + if (!opts.isCloud || opts.emailVerifiedAt) return; + + const emailSignature = computeEmailSignature( + opts.email, + opts.workspaceId, + opts.appSecret, + ); + throw new BadRequestException({ + message: + 'Please verify your email address. Check your inbox for the verification link.', + emailSignature, + }); +} export function validateSsoEnforcement(workspace: Workspace) { if (workspace.enforceSso) { diff --git a/apps/server/src/core/auth/dto/create-admin-user.dto.ts b/apps/server/src/core/auth/dto/create-admin-user.dto.ts index bdea75fe..9580f98f 100644 --- a/apps/server/src/core/auth/dto/create-admin-user.dto.ts +++ b/apps/server/src/core/auth/dto/create-admin-user.dto.ts @@ -7,11 +7,13 @@ import { } from 'class-validator'; import { CreateUserDto } from './create-user.dto'; import { Transform, TransformFnParams } from 'class-transformer'; +import { NoUrls } from '../../../common/validators/no-urls.validator'; export class CreateAdminUserDto extends CreateUserDto { @IsNotEmpty() @MinLength(1) @MaxLength(50) + @NoUrls() @Transform(({ value }: TransformFnParams) => value?.trim()) name: string; diff --git a/apps/server/src/core/auth/dto/create-user.dto.ts b/apps/server/src/core/auth/dto/create-user.dto.ts index 3362c722..bd432904 100644 --- a/apps/server/src/core/auth/dto/create-user.dto.ts +++ b/apps/server/src/core/auth/dto/create-user.dto.ts @@ -7,12 +7,14 @@ import { MinLength, } from 'class-validator'; import { Transform, TransformFnParams } from 'class-transformer'; +import { NoUrls } from '../../../common/validators/no-urls.validator'; export class CreateUserDto { @IsOptional() @MinLength(1) @MaxLength(50) @IsString() + @NoUrls() @Transform(({ value }: TransformFnParams) => value?.trim()) name: string; diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index bc907b8e..1bb2c5ee 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -17,6 +17,7 @@ import { isUserDisabled, nanoIdGen, } from '../../../common/helpers'; +import { throwIfEmailNotVerified } from '../auth.util'; import { ChangePasswordDto } from '../dto/change-password.dto'; import { MailService } from '../../../integrations/mail/mail.service'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; @@ -36,6 +37,7 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; @Injectable() export class AuthService { @@ -46,6 +48,7 @@ export class AuthService { private userTokenRepo: UserTokenRepo, private mailService: MailService, private domainService: DomainService, + private environmentService: EnvironmentService, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -69,6 +72,14 @@ export class AuthService { throw new UnauthorizedException(errorMessage); } + throwIfEmailNotVerified({ + isCloud: this.environmentService.isCloud(), + emailVerifiedAt: user.emailVerifiedAt, + email: user.email, + workspaceId, + appSecret: this.environmentService.getAppSecret(), + }); + user.lastLoginAt = new Date(); await this.userRepo.updateLastLogin(user.id, workspaceId); @@ -247,6 +258,14 @@ export class AuthService { template: emailTemplate, }); + if (this.environmentService.isCloud() && !user.emailVerifiedAt) { + await this.userRepo.updateUser( + { emailVerifiedAt: new Date() }, + user.id, + workspace.id, + ); + } + // Check if user has MFA enabled or workspace enforces MFA const userHasMfa = user?.['mfa']?.isEnabled || false; const workspaceEnforcesMfa = workspace.enforceMfa || false; diff --git a/apps/server/src/core/workspace/dto/invitation.dto.ts b/apps/server/src/core/workspace/dto/invitation.dto.ts index 8e5cccac..187688c4 100644 --- a/apps/server/src/core/workspace/dto/invitation.dto.ts +++ b/apps/server/src/core/workspace/dto/invitation.dto.ts @@ -12,6 +12,7 @@ import { MinLength, } from 'class-validator'; import { UserRole } from '../../../common/helpers/types/permission'; +import { NoUrls } from '../../../common/validators/no-urls.validator'; export class InviteUserDto { @IsArray() @@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto { @MinLength(2) @MaxLength(60) @IsString() + @NoUrls() name: string; @MinLength(8) diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 2ef80590..495057a0 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -244,7 +244,7 @@ export class WorkspaceService { await this.billingQueue.add( QueueJob.WELCOME_EMAIL, { userId: user.id }, - { delay: 60 * 1000 }, // 1m + { delay: 30 * 60 * 1000 }, // 30m ); } catch (err) { this.logger.error(err); diff --git a/apps/server/src/ee b/apps/server/src/ee index 62a8a7e5..52ac3a79 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 62a8a7e548a17b8e3baf4dfdc90f2f432a691ee0 +Subproject commit 52ac3a79de56472a1f77b12ea4cd4c07fd5f5d69 diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts index 041d0f4c..5c307da2 100644 --- a/apps/server/src/integrations/environment/environment.validation.ts +++ b/apps/server/src/integrations/environment/environment.validation.ts @@ -10,7 +10,7 @@ import { validateSync, } from 'class-validator'; import { plainToInstance } from 'class-transformer'; -import { IsISO6391 } from '../../common/validator/is-iso6391'; +import { IsISO6391 } from '../../common/validators/is-iso6391'; export class EnvironmentVariables { @IsNotEmpty() diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index fca457e5..0f2a82a1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -67,6 +67,7 @@ async function bootstrap() { '/api/sso/google', '/api/workspace/create', '/api/workspace/joined', + '/api/workspace/find-by-email', ]; if ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2049ad4e..30535f29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -681,6 +681,9 @@ importers: stripe: specifier: ^17.5.0 version: 17.5.0 + tlds: + specifier: ^1.261.0 + version: 1.261.0 tmp-promise: specifier: ^3.0.3 version: 3.0.3 @@ -9837,6 +9840,10 @@ packages: tiptap-extension-global-drag-handle@0.1.18: resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + tldts-core@6.1.72: resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==} @@ -21145,6 +21152,8 @@ snapshots: tiptap-extension-global-drag-handle@0.1.18: {} + tlds@1.261.0: {} + tldts-core@6.1.72: {} tldts@6.1.72: From 89b94e5d195005ae42ee7c8e5981ee9dc2e8cb6d Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:08:59 +0000 Subject: [PATCH 07/60] feat: refactor link menu (#2025) * link markview - WIP * WIP * feat: refactor links * cleanup --- .../public/locales/en-US/translation.json | 5 + .../src/components/ui/auto-tooltip-text.tsx | 1 + .../components/bubble-menu/link-selector.tsx | 10 +- .../components/link/link-editor-panel.tsx | 199 +++++- .../editor/components/link/link-menu.tsx | 103 ---- .../editor/components/link/link-preview.tsx | 60 -- .../editor/components/link/link-view.tsx | 583 ++++++++++++++++++ .../editor/components/link/link.module.css | 108 +++- .../features/editor/components/link/types.ts | 3 +- .../components/link/use-link-editor-state.tsx | 8 +- .../features/editor/extensions/extensions.ts | 7 +- .../editor/hooks/use-editor-scroll.ts | 2 +- .../src/features/editor/page-editor.tsx | 2 - .../src/features/editor/styles/core.css | 12 +- .../features/search/queries/search-query.ts | 3 +- apps/server/src/ee | 2 +- .../src/integrations/export/export.service.ts | 1 + apps/server/src/integrations/export/utils.ts | 5 + .../services/file-import-task.service.ts | 7 + .../import/utils/import-formatter.ts | 29 +- packages/editor-ext/src/lib/link.ts | 13 + 21 files changed, 944 insertions(+), 219 deletions(-) delete mode 100644 apps/client/src/features/editor/components/link/link-menu.tsx delete mode 100644 apps/client/src/features/editor/components/link/link-preview.tsx create mode 100644 apps/client/src/features/editor/components/link/link-view.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index da0f0b81..2d9675b2 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -289,6 +289,11 @@ "Save & Exit": "Save & Exit", "Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram", "Paste link": "Paste link", + "Paste link or search pages": "Paste link or search pages", + "Link to web page": "Link to web page", + "Recents": "Recents", + "Page or URL": "Page or URL", + "Link title": "Link title", "Edit link": "Edit link", "Remove link": "Remove link", "Add link": "Add link", diff --git a/apps/client/src/components/ui/auto-tooltip-text.tsx b/apps/client/src/components/ui/auto-tooltip-text.tsx index 419ec3d9..962a701a 100644 --- a/apps/client/src/components/ui/auto-tooltip-text.tsx +++ b/apps/client/src/components/ui/auto-tooltip-text.tsx @@ -34,6 +34,7 @@ export function AutoTooltipText({ disabled={!isTruncated || !label} multiline withArrow + withinPortal={false} {...tooltipProps} > = ({ }) => { const { t } = useTranslation(); const onLink = useCallback( - (url: string) => { + (url: string, internal?: boolean) => { setIsOpen(false); editor .chain() .focus() - .setLink({ href: url }) + .setLink({ href: internal ? url : normalizeUrl(url), internal: !!internal } as any) .command(({ tr }) => { tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); return true; @@ -36,11 +37,12 @@ export const LinkSelector: FC = ({ return ( @@ -58,7 +60,7 @@ export const LinkSelector: FC = ({ - + diff --git a/apps/client/src/features/editor/components/link/link-editor-panel.tsx b/apps/client/src/features/editor/components/link/link-editor-panel.tsx index 733455cf..d2cce213 100644 --- a/apps/client/src/features/editor/components/link/link-editor-panel.tsx +++ b/apps/client/src/features/editor/components/link/link-editor-panel.tsx @@ -1,36 +1,199 @@ -import React from "react"; -import { Button, Group, TextInput } from "@mantine/core"; -import { IconLink } from "@tabler/icons-react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Group, + ScrollArea, + Text, + TextInput, + UnstyledButton, +} from "@mantine/core"; +import { IconFileDescription, IconLink, IconWorld } from "@tabler/icons-react"; import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx"; import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts"; import { useTranslation } from "react-i18next"; +import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts"; +import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; +import { useParams } from "react-router-dom"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import { IPage } from "@/features/page/types/page.types.ts"; +import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx"; +import clsx from "clsx"; +import classes from "./link.module.css"; export const LinkEditorPanel = ({ onSetLink, initialUrl, + onUnsetLink, }: LinkEditorPanelProps) => { const { t } = useTranslation(); - const state = useLinkEditorState({ - onSetLink, - initialUrl, + const { spaceSlug } = useParams(); + const { data: space } = useSpaceQuery(spaceSlug); + const state = useLinkEditorState({ onSetLink, initialUrl }); + const [selectedIndex, setSelectedIndex] = useState(0); + const viewportRef = useRef(null); + + const { data: suggestion } = useSearchSuggestionsQuery({ + query: state.isSearchQuery ? state.url : "", + includeUsers: false, + includePages: true, + spaceId: space?.id, + limit: state.isSearchQuery ? 10 : 5, + preload: true, }); + const pages: Partial[] = suggestion?.pages ?? []; + + useEffect(() => { + setSelectedIndex(0); + }, [pages.length]); + + const selectPage = useCallback( + (page: Partial) => { + const url = buildPageUrl( + page.space?.slug || spaceSlug, + page.slugId, + page.title, + ); + onSetLink(url, true); + }, + [onSetLink, spaceSlug], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const hasUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery); + const total = (hasUrlItem ? 1 : 0) + (state.isValidUrl ? 0 : pages.length); + if (total === 0) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, total - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (hasUrlItem && selectedIndex === 0) { + onSetLink(state.url, false); + } else { + const pageIndex = hasUrlItem ? selectedIndex - 1 : selectedIndex; + if (pageIndex >= 0 && pageIndex < pages.length) { + selectPage(pages[pageIndex]); + } + } + } + }, + [pages, selectedIndex, selectPage, state.isValidUrl, state.isSearchQuery, state.url, onSetLink], + ); + + useEffect(() => { + viewportRef.current + ?.querySelector(`[data-item-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const showPages = pages.length > 0 && !state.isValidUrl; + const showUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery); + const showDropdown = showPages || showUrlItem; + return (
- - } - variant="filled" - placeholder={t("Paste link")} - value={state.url} - onChange={state.onChange} - /> - - + } + classNames={{ input: classes.linkInput }} + placeholder={t("Paste link or search pages")} + value={state.url} + onChange={state.onChange} + onKeyDown={handleKeyDown} + autoFocus + /> + + {showDropdown && ( + <> + {!state.isSearchQuery && !state.isValidUrl && ( + + {t("Recents")} + + )} + + 0 ? 8 : 0} + styles={{ content: { minWidth: 0 } }} + > + {showUrlItem && ( + onSetLink(state.url, false)} + className={clsx(classes.searchItem, { + [classes.selectedSearchItem]: selectedIndex === 0, + })} + > + + + + + +
+ + {state.url} + + + {t("Link to web page")} + +
+
+
+ )} + + {!state.isValidUrl && pages.map((page, index) => { + const itemIndex = showUrlItem ? index + 1 : index; + return ( + selectPage(page)} + className={clsx(classes.searchItem, { + [classes.selectedSearchItem]: itemIndex === selectedIndex, + })} + > + + + {page.icon || } + + +
+ + {page.title || t("Untitled")} + + {page.space?.name && ( + + {page.space.name} + + )} +
+
+
+ ); + })} +
+ + )} + + {onUnsetLink && ( + + + {t("Remove link")} + + + )}
); }; diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx deleted file mode 100644 index 81e77f57..00000000 --- a/apps/client/src/features/editor/components/link/link-menu.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; -import React, { useCallback, useState } from "react"; -import { TextSelection } from "@tiptap/pm/state"; -import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; -import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; -import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx"; -import { Card } from "@mantine/core"; -import { useEditorState } from "@tiptap/react"; - -export function LinkMenu({ editor, appendTo }: EditorMenuProps) { - const [showEdit, setShowEdit] = useState(false); - - const shouldShow = useCallback(() => { - return editor.isActive("link"); - }, [editor]); - - const editorState = useEditorState({ - editor, - selector: (ctx) => { - if (!ctx.editor) { - return null; - } - const link = ctx.editor.getAttributes("link"); - return { - href: link.href, - }; - }, - }); - - const handleEdit = useCallback(() => { - setShowEdit(true); - }, []); - - const onSetLink = useCallback( - (url: string) => { - editor - .chain() - .focus() - .extendMarkRange("link") - .setLink({ href: url }) - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); - return true; - }) - .run(); - setShowEdit(false); - }, - [editor], - ); - - const onUnsetLink = useCallback(() => { - editor.chain().focus().extendMarkRange("link").unsetLink().run(); - setShowEdit(false); - return null; - }, [editor]); - - const onShowEdit = useCallback(() => { - setShowEdit(true); - }, []); - - const onHideEdit = useCallback(() => { - setShowEdit(false); - }, []); - - return ( - { - setShowEdit(false); - }, - placement: "bottom", - offset: 5, - // zIndex: 101, - }} - shouldShow={shouldShow} - > - {showEdit ? ( - - - - ) : ( - - )} - - ); -} - -export default LinkMenu; diff --git a/apps/client/src/features/editor/components/link/link-preview.tsx b/apps/client/src/features/editor/components/link/link-preview.tsx deleted file mode 100644 index 8b0de952..00000000 --- a/apps/client/src/features/editor/components/link/link-preview.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - Tooltip, - ActionIcon, - Card, - Divider, - Anchor, - Flex, -} from "@mantine/core"; -import { IconLinkOff, IconPencil } from "@tabler/icons-react"; -import { useTranslation } from "react-i18next"; -import classes from "./link.module.css"; - -export type LinkPreviewPanelProps = { - url: string; - onEdit: () => void; - onClear: () => void; -}; - -export const LinkPreviewPanel = ({ - onClear, - onEdit, - url, -}: LinkPreviewPanelProps) => { - const { t } = useTranslation(); - - return ( - <> - - - - - {url} - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx new file mode 100644 index 00000000..90b89e82 --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -0,0 +1,583 @@ +import { MarkViewContent, MarkViewProps } from "@tiptap/react"; +import { useNavigate, useLocation, useParams } from "react-router-dom"; +import { + IconFileDescription, + IconCopy, + IconExternalLink, + IconLinkOff, + IconPencil, + IconWorld, +} from "@tabler/icons-react"; +import { useState, useCallback, useRef, useEffect } from "react"; +import { notifications } from "@mantine/notifications"; +import { + Divider, + Group, + Popover, + Text, + TextInput, + ActionIcon, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import classes from "./link.module.css"; +import { useTranslation } from "react-i18next"; +import { INTERNAL_LINK_REGEX } from "@/lib/constants"; +import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; +import { usePageQuery } from "@/features/page/queries/page-query.ts"; +import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; +import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { extractPageSlugId } from "@/lib"; +import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext"; + +export const normalizeUrl = (url: string): string => { + if (!url) return url; + if (url.startsWith("/") || /^(\S+):(\/\/)?\S+$/.test(url)) return url; + return `https://${url}`; +}; + +const parseInternalLink = ( + href: string, + internalAttr?: boolean, +): { isInternal: boolean; slugId: string | null; label: string } => { + if (!href) return { isInternal: !!internalAttr, slugId: null, label: "" }; + + const match = INTERNAL_LINK_REGEX.exec(href); + if (!match) { + if (internalAttr) return { isInternal: true, slugId: null, label: href }; + return { isInternal: false, slugId: null, label: href }; + } + + const isExternal = match[2] && match[2] !== window.location.host; + const slug = match[5]; + const slugId = extractPageSlugId(slug); + const namePart = slug.split("-").slice(0, -1).join("-"); + + return { + isInternal: !isExternal, + slugId, + label: namePart || slug, + }; +}; + +export default function LinkView(props: MarkViewProps) { + const { mark, editor } = props; + const href = mark.attrs.href as string; + const navigate = useNavigate(); + const location = useLocation(); + const { shareId, pageSlug } = useParams(); + const { t } = useTranslation(); + const isShareRoute = location.pathname.startsWith("/share"); + + const [popoverState, setPopoverState] = useState< + "closed" | "preview" | "edit" + >("closed"); + const [linkTitle, setLinkTitle] = useState(""); + const [linkUrl, setLinkUrl] = useState(""); + const [showSearch, setShowSearch] = useState(false); + const lastOpenState = useRef<"preview" | "edit">("preview"); + const wrapperRef = useRef(null); + const dropdownRef = useRef(null); + const isEditable = editor.isEditable; + const { + isInternal, + slugId, + label: linkLabel, + } = parseInternalLink(href, mark.attrs.internal); + + const isPopoverVisible = popoverState !== "closed"; + const activeView = isPopoverVisible ? popoverState : lastOpenState.current; + + const { data: linkedPage } = usePageQuery({ + pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null, + }); + + const { data: sharedPageData } = useSharePageQuery({ + pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null, + }); + + const pageTitle = isShareRoute + ? sharedPageData?.page?.title + : linkedPage?.title; + + const pendingTitleRef = useRef(null); + const titleInputRef = useRef(null); + + const getLinkPos = useCallback((): number | null => { + if (!wrapperRef.current) return null; + try { + return editor.view.posAtDOM(wrapperRef.current, 0); + } catch { + return null; + } + }, [editor]); + + const handleUpdateLinkTitle = useCallback( + (newTitle: string) => { + if (!newTitle) return; + + const pos = getLinkPos(); + if (pos === null) return; + + const { state } = editor; + const resolved = state.doc.resolve(pos); + const node = resolved.nodeAfter; + if (!node?.isText) return; + + const linkMark = node.marks.find( + (m) => m.type.name === "link" && m.attrs.href === href, + ); + if (!linkMark || node.text === newTitle) return; + + const from = pos; + const to = pos + node.nodeSize; + const { tr } = state; + tr.insertText(newTitle, from, to); + tr.addMark(from, from + newTitle.length, linkMark); + editor.view.dispatch(tr); + }, + [editor, href, getLinkPos], + ); + + const handleEditLink = useCallback( + (url: string, internal?: boolean) => { + const normalizedUrl = internal ? url : normalizeUrl(url); + + const pos = getLinkPos(); + if (pos === null) { + setPopoverState("closed"); + return; + } + + const { state } = editor; + const resolved = state.doc.resolve(pos); + const node = resolved.nodeAfter; + if (!node?.isText) { + setPopoverState("closed"); + return; + } + + const linkMark = node.marks.find( + (m) => m.type.name === "link" && m.attrs.href === href, + ); + if (linkMark) { + const from = pos; + const to = pos + node.nodeSize; + const { tr } = state; + tr.removeMark(from, to, linkMark.type); + tr.addMark( + from, + to, + linkMark.type.create({ href: normalizedUrl, internal: !!internal }), + ); + editor.view.dispatch(tr); + } + + setPopoverState("closed"); + }, + [editor, href, getLinkPos], + ); + + useEffect(() => { + if (popoverState === "edit") { + const text = wrapperRef.current?.querySelector("a")?.textContent || ""; + setLinkTitle(text); + setLinkUrl(href); + pendingTitleRef.current = null; + requestAnimationFrame(() => titleInputRef.current?.focus()); + } + if (popoverState === "closed") { + if (pendingTitleRef.current !== null) { + handleUpdateLinkTitle(pendingTitleRef.current); + pendingTitleRef.current = null; + } + setShowSearch(false); + } + }, [popoverState, href, isInternal, handleUpdateLinkTitle]); + + useEffect(() => { + if (popoverState !== "closed") { + lastOpenState.current = popoverState; + } + }, [popoverState]); + + useEffect(() => { + if (!isPopoverVisible) return; + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if ( + wrapperRef.current?.contains(target) || + dropdownRef.current?.contains(target) + ) { + return; + } + setPopoverState("closed"); + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setPopoverState("closed"); + } + }; + document.addEventListener("mousedown", handleClickOutside, true); + document.addEventListener("keydown", handleEscape, true); + return () => { + document.removeEventListener("mousedown", handleClickOutside, true); + document.removeEventListener("keydown", handleEscape, true); + }; + }, [isPopoverVisible]); + + const handleNavigate = useCallback(() => { + if (!href) return; + + if (isInternal) { + let targetPath = href; + let anchor = ""; + + try { + const url = new URL(href); + targetPath = url.pathname; + anchor = url.hash.slice(1); + } catch { + if (href.includes("#")) { + [targetPath, anchor] = href.split("#"); + } + } + + if (anchor) { + const currentPageSlugId = extractPageSlugId(pageSlug); + if (!slugId || currentPageSlugId === slugId) { + const element = + document.querySelector(`[id="${anchor}"]`) || + document.querySelector(`[data-id="${anchor}"]`); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "start" }); + navigate(`${location.pathname}#${anchor}`, { replace: true }); + return; + } + } + } + + if (isShareRoute && slugId) { + const sharedUrl = buildSharedPageUrl({ + shareId, + pageSlugId: slugId, + pageTitle: pageTitle, + anchorId: anchor || undefined, + }); + navigate(sharedUrl); + } else { + navigate(anchor ? `${targetPath}#${anchor}` : targetPath); + } + } else { + window.open( + sanitizeUrl(normalizeUrl(href)), + "_blank", + "noopener,noreferrer", + ); + } + }, [ + href, + navigate, + location.pathname, + isInternal, + isShareRoute, + slugId, + shareId, + pageTitle, + pageSlug, + ]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (isEditable) { + setPopoverState("preview"); + } else { + handleNavigate(); + } + }, + [handleNavigate, isEditable], + ); + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const fullUrl = sanitizeUrl( + isInternal ? `${window.location.origin}${href}` : href, + ); + copyToClipboard(fullUrl); + notifications.show({ + message: t("Link copied"), + }); + setPopoverState("closed"); + }, + [href, isInternal, t], + ); + + const handleRemoveLink = useCallback(() => { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + setPopoverState("closed"); + }, [editor]); + + const displayHref = sanitizeUrl( + isInternal + ? isShareRoute && slugId + ? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle }) + : href + : normalizeUrl(href), + ); + + const linkTitleInput = ( + <> + + {t("Link title")} + + { + const val = e.currentTarget.value; + setLinkTitle(val); + pendingTitleRef.current = val; + const anchor = wrapperRef.current?.querySelector("a"); + if (anchor && val) { + const walker = document.createTreeWalker( + anchor, + NodeFilter.SHOW_TEXT, + ); + const textNode = walker.nextNode(); + if (textNode) { + const view = editor.view as any; + view.domObserver.stop(); + textNode.nodeValue = val; + view.domObserver.start(); + } + } + }} + onBlur={() => { + if (pendingTitleRef.current !== null) { + handleUpdateLinkTitle(pendingTitleRef.current); + pendingTitleRef.current = null; + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleUpdateLinkTitle(linkTitle); + pendingTitleRef.current = null; + setPopoverState("closed"); + } + }} + size="sm" + /> + + ); + + return ( + + + + e.preventDefault()} + target={isInternal ? undefined : "_blank"} + rel={isInternal ? undefined : "noopener noreferrer"} + > + + + + + + e.stopPropagation()} + > + {activeView === "edit" ? ( + <> + + {t("Page or URL")} + + + {isInternal ? ( + !showSearch ? ( + <> + setShowSearch(true)} + > + + + {pageTitle || linkTitle} + + + + {linkTitleInput} + + + + + + + {t("Remove link")} + + + + ) : ( + + ) + ) : ( + <> + + } + classNames={{ input: classes.linkInput }} + value={linkUrl} + onChange={(e) => setLinkUrl(e.currentTarget.value)} + onBlur={() => { + if (linkUrl && linkUrl !== href) { + handleEditLink(linkUrl, false); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (linkUrl && linkUrl !== href) { + handleEditLink(linkUrl, false); + } + } + }} + size="sm" + /> + + {linkTitleInput} + + + + + + + {t("Remove link")} + + + + )} + + ) : ( + + { + e.preventDefault(); + handleNavigate(); + }} + > + {isInternal ? ( + + ) : ( + + )} + + {isInternal ? pageTitle || linkLabel : href} + + + + + + + { + e.preventDefault(); + e.stopPropagation(); + setShowSearch(false); + setPopoverState("edit"); + }} + > + + + + + + { + e.preventDefault(); + e.stopPropagation(); + handleCopy(e); + }} + > + + + + + + { + e.preventDefault(); + e.stopPropagation(); + handleRemoveLink(); + }} + > + + + + + )} + + + ); +} diff --git a/apps/client/src/features/editor/components/link/link.module.css b/apps/client/src/features/editor/components/link/link.module.css index 2168997f..dab288c2 100644 --- a/apps/client/src/features/editor/components/link/link.module.css +++ b/apps/client/src/features/editor/components/link/link.module.css @@ -1,6 +1,102 @@ -.link { - color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} \ No newline at end of file +.linkWrapper { + position: relative; + display: inline; +} + +.linkInput { + border: 1.5px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); + background: transparent; + + &:focus { + border-color: light-dark( + var(--mantine-color-blue-4), + var(--mantine-color-blue-6) + ); + box-shadow: 0 0 0 1px + light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6)); + } +} + +.pageIcon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + color: var(--mantine-color-dimmed); + font-size: 16px; + margin-top: 2px; +} + +.searchItem { + width: 100%; + padding: 7px 4px; + color: var(--mantine-color-text); + border-radius: var(--mantine-radius-sm); + + &:hover { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } + } +} + +.selectedSearchItem { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-gray-light); + } +} + +.linkChip { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border-radius: var(--mantine-radius-sm); + cursor: pointer; + + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-dark-5); + } + + &:hover { + @mixin light { + background: var(--mantine-color-gray-2); + } + + @mixin dark { + background: var(--mantine-color-dark-4); + } + } +} + +.removeLink { + width: 100%; + padding: 4px; + border-radius: var(--mantine-radius-sm); + + &:hover { + @mixin light { + background: var(--mantine-color-gray-1); + } + + @mixin dark { + background: var(--mantine-color-dark-5); + } + } +} diff --git a/apps/client/src/features/editor/components/link/types.ts b/apps/client/src/features/editor/components/link/types.ts index 853dcae6..0bfedd08 100644 --- a/apps/client/src/features/editor/components/link/types.ts +++ b/apps/client/src/features/editor/components/link/types.ts @@ -1,4 +1,5 @@ export type LinkEditorPanelProps = { initialUrl?: string; - onSetLink: (url: string, openInNewTab?: boolean) => void; + onSetLink: (url: string, internal?: boolean) => void; + onUnsetLink?: () => void; }; diff --git a/apps/client/src/features/editor/components/link/use-link-editor-state.tsx b/apps/client/src/features/editor/components/link/use-link-editor-state.tsx index 778f8da7..0419ee64 100644 --- a/apps/client/src/features/editor/components/link/use-link-editor-state.tsx +++ b/apps/client/src/features/editor/components/link/use-link-editor-state.tsx @@ -13,11 +13,16 @@ export const useLinkEditorState = ({ const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]); + const isSearchQuery = useMemo( + () => url.length > 0 && !isValidUrl, + [url, isValidUrl], + ); + const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); if (isValidUrl) { - onSetLink(url); + onSetLink(url, false); } }, [url, isValidUrl, onSetLink], @@ -29,5 +34,6 @@ export const useLinkEditorState = ({ onChange, handleSubmit, isValidUrl, + isSearchQuery, }; }; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 22c595e0..0532156b 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -86,8 +86,9 @@ import fortran from "highlight.js/lib/languages/fortran"; import haskell from "highlight.js/lib/languages/haskell"; import scala from "highlight.js/lib/languages/scala"; import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts"; -import { ReactNodeViewRenderer } from "@tiptap/react"; +import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react"; import MentionView from "@/features/editor/components/mention/mention-view.tsx"; +import LinkView from "@/features/editor/components/link/link-view.tsx"; import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import EmojiCommand from "./emoji-command"; @@ -176,6 +177,10 @@ export const mainExtensions = [ }), LinkExtension.configure({ openOnClick: false, + }).extend({ + addMarkView() { + return ReactMarkViewRenderer(LinkView); + }, }), Superscript, SubScript, diff --git a/apps/client/src/features/editor/hooks/use-editor-scroll.ts b/apps/client/src/features/editor/hooks/use-editor-scroll.ts index 31c357a0..cfd5a692 100644 --- a/apps/client/src/features/editor/hooks/use-editor-scroll.ts +++ b/apps/client/src/features/editor/hooks/use-editor-scroll.ts @@ -42,7 +42,7 @@ export const useEditorScroll = ({ return; } - const dom = editor.view.dom.querySelector(`[id="${targetId}"]`); + const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`); if (dom) { dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); resolve(true); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index d0d1de03..250d47a3 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -50,7 +50,6 @@ import { handleFileDrop, handlePaste, } from "@/features/editor/components/common/editor-paste-handler.tsx"; -import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; @@ -418,7 +417,6 @@ export default function PageEditor({ -
)} {showCommentPopup && } diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css index 8bcfa805..1f8861f3 100644 --- a/apps/client/src/features/editor/styles/core.css +++ b/apps/client/src/features/editor/styles/core.css @@ -98,12 +98,12 @@ a { color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); @mixin light { - border-bottom: 0.05em solid var(--mantine-color-dark-0); + border-bottom: 0.07em solid var(--mantine-color-dark-0); } @mixin dark { - border-bottom: 0.05em solid var(--mantine-color-dark-2); + border-bottom: 0.07em solid var(--mantine-color-dark-2); } - /*font-weight: 500; */ + font-weight: 500; text-decoration: none; cursor: pointer; } @@ -223,13 +223,13 @@ .ProseMirror > h4, .ProseMirror > h5, .ProseMirror > h6 { - + > .link-btn { cursor: pointer; position: relative; } - + > .link-btn > .link-btn-content { opacity: 0; position: absolute; @@ -241,7 +241,7 @@ justify-content: center; flex-direction: column; } - + &:hover > .link-btn > .link-btn-content { opacity: 1; } diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index 59a05c86..f536b441 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -1,4 +1,4 @@ -import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query"; import { searchAttachments, searchPage, @@ -32,6 +32,7 @@ export function useSearchSuggestionsQuery( staleTime: 60 * 1000, // 1min queryFn: () => searchSuggestions(queryParams), enabled: preload || !!params.query, + placeholderData: keepPreviousData, }); } diff --git a/apps/server/src/ee b/apps/server/src/ee index 52ac3a79..bc4255a5 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 52ac3a79de56472a1f77b12ea4cd4c07fd5f5d69 +Subproject commit bc4255a585bbe2cebccdf9fa6a9fe3389be58956 diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts index c8cea483..64f226a2 100644 --- a/apps/server/src/integrations/export/export.service.ts +++ b/apps/server/src/integrations/export/export.service.ts @@ -291,6 +291,7 @@ export class ExportService { prosemirrorJson, slugIdToPath, currentPagePath, + baseUrl, ); if (includeAttachments) { diff --git a/apps/server/src/integrations/export/utils.ts b/apps/server/src/integrations/export/utils.ts index 266141c2..ba021be3 100644 --- a/apps/server/src/integrations/export/utils.ts +++ b/apps/server/src/integrations/export/utils.ts @@ -62,6 +62,7 @@ export function replaceInternalLinks( prosemirrorJson: any, slugIdToPath: Record, currentPagePath: string, + baseUrl?: string, ) { const doc = jsonToNode(prosemirrorJson); @@ -76,6 +77,10 @@ export function replaceInternalLinks( const localPath = slugIdToPath[slugId]; if (!localPath) { + if (baseUrl && mark.attrs.href.startsWith('/')) { + //@ts-expect-error + mark.attrs.href = `${baseUrl}${mark.attrs.href}`; + } continue; } diff --git a/apps/server/src/integrations/import/services/file-import-task.service.ts b/apps/server/src/integrations/import/services/file-import-task.service.ts index 6fc223a8..8ff1cadc 100644 --- a/apps/server/src/integrations/import/services/file-import-task.service.ts +++ b/apps/server/src/integrations/import/services/file-import-task.service.ts @@ -164,6 +164,12 @@ export class FileImportTaskService { const attachmentCandidates = await buildAttachmentCandidates(extractDir); const docmostMetadata = await readDocmostMetadata(extractDir); + const space = await this.db + .selectFrom('spaces') + .select(['slug']) + .where('id', '=', fileTask.spaceId) + .executeTakeFirst(); + const pagesMap = new Map(); for (const absPath of allFiles) { @@ -458,6 +464,7 @@ export class FileImportTaskService { creatorId: fileTask.creatorId, sourcePageId: page.id, workspaceId: fileTask.workspaceId, + spaceSlug: space?.slug, }); const pmState = getProsemirrorContent( diff --git a/apps/server/src/integrations/import/utils/import-formatter.ts b/apps/server/src/integrations/import/utils/import-formatter.ts index 4f6a1a80..2d4bca7b 100644 --- a/apps/server/src/integrations/import/utils/import-formatter.ts +++ b/apps/server/src/integrations/import/utils/import-formatter.ts @@ -1,9 +1,10 @@ import { getEmbedUrlAndProvider } from '@docmost/editor-ext'; import { Logger } from '@nestjs/common'; import * as path from 'path'; -import { v7 } from 'uuid'; import { InsertableBacklink } from '@docmost/db/types/entity.types'; import { Cheerio, CheerioAPI, load } from 'cheerio'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import slugify = require('@sindresorhus/slugify'); // Check if text contains Unicode characters (for emojis/icons) function isUnicodeCharacter(text: string): boolean { @@ -22,6 +23,7 @@ export async function formatImportHtml(opts: { workspaceId: string; pageDir?: string; attachmentCandidates?: string[]; + spaceSlug?: string; }): Promise<{ html: string; backlinks: InsertableBacklink[]; @@ -61,6 +63,7 @@ export async function formatImportHtml(opts: { creatorId, sourcePageId, workspaceId, + opts.spaceSlug, ); return { @@ -316,6 +319,7 @@ export async function rewriteInternalLinksToMentionHtml( creatorId: string, sourcePageId: string, workspaceId: string, + spaceSlug?: string, ): Promise { const normalize = (p: string) => p.replace(/\\/g, '/'); const backlinks: InsertableBacklink[] = []; @@ -339,19 +343,16 @@ export async function rewriteInternalLinksToMentionHtml( ); const meta = filePathToPageMetaMap.get(resolved); if (!meta) return; - const mentionId = v7(); - const $mention = $('') - .attr({ - 'data-type': 'mention', - 'data-id': mentionId, - 'data-entity-type': 'page', - 'data-entity-id': meta.id, - 'data-label': meta.title, - 'data-slug-id': meta.slugId, - 'data-creator-id': creatorId, - }) - .text(meta.title); - $a.replaceWith($mention); + + const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled'); + const pageSlug = `${titleSlug}-${meta.slugId}`; + const internalHref = spaceSlug + ? `/s/${spaceSlug}/p/${pageSlug}` + : `/p/${pageSlug}`; + + $a.attr('href', internalHref); + $a.attr('data-internal', 'true'); + backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId }); }); diff --git a/packages/editor-ext/src/lib/link.ts b/packages/editor-ext/src/lib/link.ts index 5357e6aa..1d4cb207 100644 --- a/packages/editor-ext/src/lib/link.ts +++ b/packages/editor-ext/src/lib/link.ts @@ -6,6 +6,19 @@ import { EditorView } from "@tiptap/pm/view"; export const LinkExtension = TiptapLink.extend({ inclusive: false, + addAttributes() { + return { + ...this.parent?.(), + internal: { + default: false, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-internal') === 'true', + renderHTML: (attributes) => + attributes.internal ? { 'data-internal': 'true' } : {}, + }, + }; + }, + parseHTML() { return [ { From 236a63dadc70440311526732601a158b7fd94c1f Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:09:29 +0000 Subject: [PATCH 08/60] sync --- apps/server/src/ee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ee b/apps/server/src/ee index bc4255a5..47e76280 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit bc4255a585bbe2cebccdf9fa6a9fe3389be58956 +Subproject commit 47e76280fd55b2ff8f6fc755ec1b52876ca4101e From d7a5fda53c955bce1048ced07aa0f8dc1b959fc9 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:05:32 +0000 Subject: [PATCH 09/60] feat: better feature flags (#2026) * feat: feature flag upgrade * fix translations * refactor * fix * fix --- .../public/locales/en-US/translation.json | 25 ++--- .../components/settings/settings-sidebar.tsx | 106 ++++++------------ .../src/ee/ai/components/enable-ai-search.tsx | 32 +++--- .../ee/ai/components/enable-generative-ai.tsx | 21 ++-- .../src/ee/ai/components/mcp-settings.tsx | 84 ++++++++------ apps/client/src/ee/ai/pages/ai-settings.tsx | 9 +- .../components/restrict-api-to-admins.tsx | 9 +- .../src/ee/api-key/pages/user-api-keys.tsx | 13 ++- .../ee/api-key/pages/workspace-api-keys.tsx | 14 +-- apps/client/src/ee/components/sso-login.tsx | 3 +- .../src/ee/entitlement/entitlement-atom.ts | 7 ++ .../src/ee/entitlement/entitlement-service.ts | 7 ++ .../src/ee/entitlement/entitlement.types.ts | 7 ++ .../src/ee/entitlement/use-entitlements.ts | 11 ++ apps/client/src/ee/features.ts | 19 ++++ .../src/ee/hooks/use-enterprise-access.tsx | 12 -- apps/client/src/ee/hooks/use-feature.ts | 7 ++ apps/client/src/ee/hooks/use-license.tsx | 9 -- apps/client/src/ee/hooks/use-upgrade-label.ts | 16 +++ .../components/activate-license-modal.tsx | 11 +- .../ee/licence/components/license-details.tsx | 3 +- apps/client/src/ee/licence/pages/license.tsx | 7 +- .../src/ee/licence/queries/license-query.ts | 2 + .../src/ee/licence/types/license.types.ts | 3 + .../src/ee/mfa/components/mfa-settings.tsx | 12 +- .../components/page-share-modal.tsx | 11 +- .../components/disable-public-sharing.tsx | 29 +++-- .../ee/security/components/enforce-mfa.tsx | 33 ++++-- .../ee/security/components/enforce-sso.tsx | 26 +++-- .../space-public-sharing-toggle.tsx | 12 +- .../security/components/trash-retention.tsx | 47 +++++--- .../client/src/ee/security/pages/security.tsx | 37 +++--- .../comment/components/comment-list-item.tsx | 9 +- .../comment/components/comment-menu.tsx | 52 +++++---- .../components/notification-item.tsx | 25 +++-- .../page/components/page-import-modal.tsx | 16 +-- .../components/search-spotlight-filters.tsx | 8 +- .../search/components/search-spotlight.tsx | 11 +- .../search/hooks/use-unified-search.ts | 8 +- .../features/share/components/share-shell.tsx | 2 +- .../src/features/share/types/share.types.ts | 4 +- .../space/components/space-details.tsx | 4 +- .../src/features/user/user-provider.tsx | 12 +- .../workspace/types/workspace.types.ts | 2 - apps/client/src/hooks/use-is-cloud-ee.tsx | 7 -- apps/client/src/pages/share/shared-page.tsx | 2 +- apps/server/src/common/helpers/utils.ts | 9 -- .../server/src/core/share/share.controller.ts | 23 ++-- .../src/core/space/services/space.service.ts | 4 +- apps/server/src/core/user/user.controller.ts | 1 - .../controllers/workspace.controller.ts | 21 ++++ .../workspace/services/workspace.service.ts | 42 ++++--- apps/server/src/ee | 2 +- .../environment/license-check.service.ts | 71 ++++++++++++ 54 files changed, 585 insertions(+), 394 deletions(-) create mode 100644 apps/client/src/ee/entitlement/entitlement-atom.ts create mode 100644 apps/client/src/ee/entitlement/entitlement-service.ts create mode 100644 apps/client/src/ee/entitlement/entitlement.types.ts create mode 100644 apps/client/src/ee/entitlement/use-entitlements.ts create mode 100644 apps/client/src/ee/features.ts delete mode 100644 apps/client/src/ee/hooks/use-enterprise-access.tsx create mode 100644 apps/client/src/ee/hooks/use-feature.ts delete mode 100644 apps/client/src/ee/hooks/use-license.tsx create mode 100644 apps/client/src/ee/hooks/use-upgrade-label.ts delete mode 100644 apps/client/src/hooks/use-is-cloud-ee.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 2d9675b2..f35ec179 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -444,7 +444,6 @@ "Toggle space public sharing": "Toggle space public sharing", "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", - "Requires an enterprise license": "Requires an enterprise license", "Page permissions": "Page permissions", "Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.", "Enable public sharing": "Enable public sharing", @@ -626,7 +625,9 @@ "Generative AI (Ask AI)": "Generative AI (Ask AI)", "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.", "Toggle generative AI": "Toggle generative AI", - "Enterprise feature": "Enterprise feature", + "Upgrade your plan": "Upgrade your plan", + "Available with a paid license": "Available with a paid license", + "Upgrade your license tier.": "Upgrade your license tier.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", @@ -634,17 +635,15 @@ "Model Context Protocol (MCP)": "Model Context Protocol (MCP)", "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", - "MCP documentation": "MCP documentation", "MCP Server URL": "MCP Server URL", "Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.", "Supported tools": "Supported tools", "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.", "MCP server URL:": "MCP server URL:", "Learn more": "Learn more", - "View the": "View the", - "for usage details.": "for usage details.", - "for setup instructions.": "for setup instructions.", - "API documentation": "API documentation", + "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Manage API keys for all users in the workspace. View the API documentation for usage details.", + "View the API documentation for usage details.": "View the API documentation for usage details.", + "View the MCP documentation.": "View the MCP documentation.", "Sources": "Sources", "AI Answers not available for attachments": "AI Answers not available for attachments", "No answer available": "No answer available", @@ -659,12 +658,12 @@ "Mark all as read": "Mark all as read", "Mark as read": "Mark as read", "More options": "More options", - "mentioned you in a comment": "mentioned you in a comment", - "commented on a page": "commented on a page", - "resolved a comment": "resolved a comment", - "mentioned you on a page": "mentioned you on a page", - "gave you edit access to a page": "gave you edit access to a page", - "gave you view access to a page": "gave you view access to a page", + "{{name}} mentioned you in a comment": "{{name}} mentioned you in a comment", + "{{name}} commented on a page": "{{name}} commented on a page", + "{{name}} resolved a comment": "{{name}} resolved a comment", + "{{name}} mentioned you on a page": "{{name}} mentioned you on a page", + "{{name}} gave you edit access to a page": "{{name}} gave you edit access to a page", + "{{name}} gave you view access to a page": "{{name}} gave you view access to a page", "Today": "Today", "Yesterday": "Yesterday", "This week": "This week", diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 6ac3587f..4b5c79bf 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -21,7 +21,9 @@ import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; import useUserRole from "@/hooks/use-user-role.tsx"; import { useAtom } from "jotai"; -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { prefetchApiKeyManagement, prefetchApiKeys, @@ -39,22 +41,19 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; -interface DataItem { +type DataItem = { label: string; icon: React.ElementType; path: string; - isCloud?: boolean; - isEnterprise?: boolean; - isAdmin?: boolean; - isOwner?: boolean; - isSelfhosted?: boolean; - showDisabledInNonEE?: boolean; -} + feature?: string; + role?: "admin" | "owner"; + env?: "cloud" | "selfhosted"; +}; -interface DataGroup { +type DataGroup = { heading: string; items: DataItem[]; -} +}; const groupedData: DataGroup[] = [ { @@ -70,9 +69,7 @@ const groupedData: DataGroup[] = [ label: "API keys", icon: IconKey, path: "/settings/account/api-keys", - isCloud: true, - isEnterprise: true, - showDisabledInNonEE: true, + feature: Feature.API_KEYS, }, ], }, @@ -80,26 +77,20 @@ const groupedData: DataGroup[] = [ heading: "Workspace", items: [ { label: "General", icon: IconSettings, path: "/settings/workspace" }, - { - label: "Members", - icon: IconUsers, - path: "/settings/members", - }, + { label: "Members", icon: IconUsers, path: "/settings/members" }, { label: "Billing", icon: IconCoin, path: "/settings/billing", - isCloud: true, - isAdmin: true, + role: "admin", + env: "cloud", }, { label: "Security & SSO", icon: IconLock, path: "/settings/security", - isCloud: true, - isEnterprise: true, - isAdmin: true, - showDisabledInNonEE: true, + feature: Feature.SECURITY_SETTINGS, + role: "admin", }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, @@ -108,25 +99,22 @@ const groupedData: DataGroup[] = [ label: "API management", icon: IconKey, path: "/settings/api-keys", - isCloud: true, - isEnterprise: true, - isAdmin: true, - showDisabledInNonEE: true, + feature: Feature.API_KEYS, + role: "admin", }, { label: "AI settings", icon: IconSparkles, path: "/settings/ai", - isAdmin: true, + role: "admin", }, { label: "Audit log", icon: IconHistory, path: "/settings/audit", - isEnterprise: true, - isOwner: true, - isSelfhosted: true, - showDisabledInNonEE: true, + feature: Feature.AUDIT_LOGS, + role: "owner", + env: "selfhosted", }, ], }, @@ -148,7 +136,8 @@ export default function SettingsSidebar() { const [active, setActive] = useState(location.pathname); const { goBack } = useSettingsNavigation(); const { isAdmin, isOwner } = useUserRole(); - const [workspace] = useAtom(workspaceAtom); + const [entitlements] = useAtom(entitlementAtom); + const upgradeLabel = useUpgradeLabel(); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); @@ -156,43 +145,20 @@ export default function SettingsSidebar() { setActive(location.pathname); }, [location.pathname]); - const hasRoleAccess = (item: DataItem) => { - if (item.isOwner) return isOwner; - if (item.isAdmin) return isAdmin; + const hasFeature = (f: string) => + entitlements?.features?.includes(f) ?? false; + + const canShowItem = (item: DataItem) => { + if (item.env === "cloud" && !isCloud()) return false; + if (item.env === "selfhosted" && isCloud()) return false; + if (item.role === "admin" && !isAdmin) return false; + if (item.role === "owner" && !isOwner) return false; return true; }; - const canShowItem = (item: DataItem) => { - if (item.showDisabledInNonEE && item.isEnterprise) { - if (item.isSelfhosted && isCloud()) return false; - return hasRoleAccess(item); - } - - if (item.isCloud && item.isEnterprise) { - if (!(isCloud() || workspace?.hasLicenseKey)) return false; - return hasRoleAccess(item); - } - - if (item.isCloud) { - return isCloud() ? hasRoleAccess(item) : false; - } - - if (item.isSelfhosted) { - return !isCloud() ? hasRoleAccess(item) : false; - } - - if (item.isEnterprise) { - return workspace?.hasLicenseKey ? hasRoleAccess(item) : false; - } - - return hasRoleAccess(item); - }; - const isItemDisabled = (item: DataItem) => { - if (item.showDisabledInNonEE && item.isEnterprise) { - return !(isCloud() || workspace?.hasLicenseKey); - } - return false; + if (!item.feature) return false; + return !hasFeature(item.feature); }; const menuItems = groupedData.map((group) => { @@ -225,7 +191,7 @@ export default function SettingsSidebar() { prefetchHandler = prefetchBilling; break; case "License & Edition": - if (workspace?.hasLicenseKey) { + if (entitlements?.tier !== "free") { prefetchHandler = prefetchLicense; } break; @@ -280,7 +246,7 @@ export default function SettingsSidebar() { return ( diff --git a/apps/client/src/ee/ai/components/enable-ai-search.tsx b/apps/client/src/ee/ai/components/enable-ai-search.tsx index 91242804..3a2abd26 100644 --- a/apps/client/src/ee/ai/components/enable-ai-search.tsx +++ b/apps/client/src/ee/ai/components/enable-ai-search.tsx @@ -1,12 +1,13 @@ -import { Group, Text, Switch, MantineSize, Title } from "@mantine/core"; +import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { notifications } from "@mantine/notifications"; -import { isCloud } from "@/lib/config.ts"; -import useLicense from "@/ee/hooks/use-license.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; export default function EnableAiSearch() { const { t } = useTranslation(); @@ -37,9 +38,8 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); const [checked, setChecked] = useState(workspace?.settings?.ai?.search); - const { hasLicenseKey } = useLicense(); - - const hasAccess = isCloud() || (!isCloud() && hasLicenseKey); + const hasAccess = useHasFeature(Feature.AI); + const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; @@ -56,14 +56,16 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) { }; return ( - + + + ); } diff --git a/apps/client/src/ee/ai/components/enable-generative-ai.tsx b/apps/client/src/ee/ai/components/enable-generative-ai.tsx index 9e09f4f0..1db611ce 100644 --- a/apps/client/src/ee/ai/components/enable-generative-ai.tsx +++ b/apps/client/src/ee/ai/components/enable-generative-ai.tsx @@ -1,17 +1,20 @@ -import { Group, Text, Switch } from "@mantine/core"; +import { Group, Text, Switch, Tooltip } from "@mantine/core"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { notifications } from "@mantine/notifications"; -import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; export default function EnableGenerativeAi() { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); const [checked, setChecked] = useState(workspace?.settings?.ai?.generative); - const hasAccess = useIsCloudEE(); + const hasAccess = useHasFeature(Feature.AI); + const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; @@ -38,11 +41,13 @@ export default function EnableGenerativeAi() { - + + + ); } diff --git a/apps/client/src/ee/ai/components/mcp-settings.tsx b/apps/client/src/ee/ai/components/mcp-settings.tsx index d39d285b..e7cc2234 100644 --- a/apps/client/src/ee/ai/components/mcp-settings.tsx +++ b/apps/client/src/ee/ai/components/mcp-settings.tsx @@ -13,10 +13,12 @@ import { import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { notifications } from "@mantine/notifications"; -import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { getAppUrl } from "@/lib/config.ts"; import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; import { CopyButton } from "@/components/common/copy-button.tsx"; @@ -25,7 +27,8 @@ export default function McpSettings() { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp); - const hasAccess = useIsCloudEE(); + const hasAccess = useHasFeature(Feature.MCP); + const upgradeLabel = useUpgradeLabel(); const mcpUrl = `${getAppUrl()}/mcp`; @@ -46,11 +49,7 @@ export default function McpSettings() { return ( {!hasAccess && ( - } - title={t("Enterprise feature")} - color="blue" - > + } title={upgradeLabel} color="blue"> {t( "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", )} @@ -64,23 +63,22 @@ export default function McpSettings() { {t( "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", )}{" "} - {t("View the")}{" "} - - {t("MCP documentation")} - - . + , + }} + /> - + + + {checked && ( @@ -89,11 +87,7 @@ export default function McpSettings() { {t("MCP Server URL")} - + {({ copied, copy }) => ( - search_pages, get_page, create_page, update_page - list_pages, list_child_pages, duplicate_page - copy_page_to_space, move_page, move_page_to_space - get_space, list_spaces, create_space, update_space - get_comments, create_comment, update_comment - search_attachments, list_workspace_members, get_current_user + + + search_pages, get_page, create_page, update_page + + + + + list_pages, list_child_pages, duplicate_page + + + + + copy_page_to_space, move_page, move_page_to_space + + + + + get_space, list_spaces, create_space, update_space + + + + + get_comments, create_comment, update_comment + + + + + search_attachments, list_workspace_members, get_current_user + + diff --git a/apps/client/src/ee/ai/pages/ai-settings.tsx b/apps/client/src/ee/ai/pages/ai-settings.tsx index 53fa9a87..c3f93810 100644 --- a/apps/client/src/ee/ai/pages/ai-settings.tsx +++ b/apps/client/src/ee/ai/pages/ai-settings.tsx @@ -9,14 +9,17 @@ import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx"; import McpSettings from "@/ee/ai/components/mcp-settings.tsx"; import { Alert, Stack, Tabs } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; -import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { isCloud } from "@/lib/config.ts"; import { useLocation, useNavigate } from "react-router-dom"; export default function AiSettings() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const hasAccess = useIsCloudEE(); + const hasAccess = useHasFeature(Feature.AI); + const upgradeLabel = useUpgradeLabel(); const location = useLocation(); const navigate = useNavigate(); @@ -55,7 +58,7 @@ export default function AiSettings() { {!hasAccess && ( } - title={t("Enterprise feature")} + title={upgradeLabel} color="blue" mb="lg" > diff --git a/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx b/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx index accbc545..356d3dcb 100644 --- a/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx +++ b/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx @@ -5,12 +5,14 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { notifications } from "@mantine/notifications"; -import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl, } from "@/components/ui/responsive-settings-row"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts"; export default function RestrictApiToAdmins() { const { t } = useTranslation(); @@ -18,7 +20,8 @@ export default function RestrictApiToAdmins() { const [checked, setChecked] = useState( workspace?.settings?.api?.restrictToAdmins === true, ); - const hasAccess = useEnterpriseAccess(); + const hasAccess = useHasFeature(Feature.API_KEYS); + const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; @@ -51,7 +54,7 @@ export default function RestrictApiToAdmins() { diff --git a/apps/client/src/ee/api-key/pages/user-api-keys.tsx b/apps/client/src/ee/api-key/pages/user-api-keys.tsx index e4951805..c305f4af 100644 --- a/apps/client/src/ee/api-key/pages/user-api-keys.tsx +++ b/apps/client/src/ee/api-key/pages/user-api-keys.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import { Helmet } from "react-helmet-async"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import SettingsTitle from "@/components/settings/settings-title"; import { getAppName, getAppUrl } from "@/lib/config"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; @@ -58,11 +58,12 @@ export default function UserApiKeys() { - {t("View the")}{" "} - - {t("API documentation")} - {" "} - {t("for usage details.")} + , + }} + /> {mcpEnabled && canCreate && ( diff --git a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx index 155f7651..8476f445 100644 --- a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx +++ b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core"; import { Helmet } from "react-helmet-async"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import SettingsTitle from "@/components/settings/settings-title"; import { getAppName } from "@/lib/config"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; @@ -56,12 +56,12 @@ export default function WorkspaceApiKeys() { - {t("Manage API keys for all users in the workspace.")}{" "} - {t("View the")}{" "} - - {t("API documentation")} - {" "} - {t("for usage details.")} + , + }} + /> diff --git a/apps/client/src/ee/components/sso-login.tsx b/apps/client/src/ee/components/sso-login.tsx index 8c96d9c5..ff739dd3 100644 --- a/apps/client/src/ee/components/sso-login.tsx +++ b/apps/client/src/ee/components/sso-login.tsx @@ -6,7 +6,6 @@ import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { GoogleIcon } from "@/components/icons/google-icon.tsx"; -import { isCloud } from "@/lib/config.ts"; import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx"; export default function SsoLogin() { @@ -57,7 +56,7 @@ export default function SsoLogin() { /> )} - {(isCloud() || data.hasLicenseKey) && ( + {data.authProviders.length > 0 && ( <> {data.authProviders.map((provider) => ( diff --git a/apps/client/src/ee/entitlement/entitlement-atom.ts b/apps/client/src/ee/entitlement/entitlement-atom.ts new file mode 100644 index 00000000..e6d38512 --- /dev/null +++ b/apps/client/src/ee/entitlement/entitlement-atom.ts @@ -0,0 +1,7 @@ +import { atomWithStorage } from "jotai/utils"; +import type { Entitlements } from "./entitlement.types"; + +export const entitlementAtom = atomWithStorage( + "entitlements", + null, +); diff --git a/apps/client/src/ee/entitlement/entitlement-service.ts b/apps/client/src/ee/entitlement/entitlement-service.ts new file mode 100644 index 00000000..0bc0c9ea --- /dev/null +++ b/apps/client/src/ee/entitlement/entitlement-service.ts @@ -0,0 +1,7 @@ +import api from "@/lib/api-client"; +import { Entitlements } from "./entitlement.types"; + +export async function getEntitlements(): Promise { + const req = await api.post("/workspace/entitlements"); + return req.data as Entitlements; +} diff --git a/apps/client/src/ee/entitlement/entitlement.types.ts b/apps/client/src/ee/entitlement/entitlement.types.ts new file mode 100644 index 00000000..2ec3ab3b --- /dev/null +++ b/apps/client/src/ee/entitlement/entitlement.types.ts @@ -0,0 +1,7 @@ +export type Tier = "free" | "standard" | "business" | "enterprise"; + +export type Entitlements = { + cloud: boolean; + tier: Tier; + features: string[]; +}; diff --git a/apps/client/src/ee/entitlement/use-entitlements.ts b/apps/client/src/ee/entitlement/use-entitlements.ts new file mode 100644 index 00000000..d4bfeaf8 --- /dev/null +++ b/apps/client/src/ee/entitlement/use-entitlements.ts @@ -0,0 +1,11 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { getEntitlements } from "./entitlement-service"; +import { Entitlements } from "./entitlement.types"; + +export function useEntitlements(): UseQueryResult { + return useQuery({ + queryKey: ["entitlements"], + queryFn: getEntitlements, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts new file mode 100644 index 00000000..70438cba --- /dev/null +++ b/apps/client/src/ee/features.ts @@ -0,0 +1,19 @@ +export const Feature = { + SSO_CUSTOM: 'sso:custom', + SSO_GOOGLE: 'sso:google', + MFA: 'mfa', + API_KEYS: 'api:keys', + COMMENT_RESOLUTION: 'comment:resolution', + PAGE_PERMISSIONS: 'page:permissions', + AI: 'ai', + CONFLUENCE_IMPORT: 'import:confluence', + DOCX_IMPORT: 'import:docx', + ATTACHMENT_INDEXING: 'attachment:indexing', + SECURITY_SETTINGS: 'security:settings', + MCP: 'mcp', + SCIM: 'scim', + PAGE_VERIFICATION: 'page:verification', + AUDIT_LOGS: 'audit:logs', + RETENTION: 'retention', + SHARING_CONTROLS: 'sharing:controls', +} as const; diff --git a/apps/client/src/ee/hooks/use-enterprise-access.tsx b/apps/client/src/ee/hooks/use-enterprise-access.tsx deleted file mode 100644 index b7746d6f..00000000 --- a/apps/client/src/ee/hooks/use-enterprise-access.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { isCloud } from "@/lib/config"; -import useLicense from "@/ee/hooks/use-license"; -import usePlan from "@/ee/hooks/use-plan"; - -const useEnterpriseAccess = () => { - const { hasLicenseKey } = useLicense(); - const { isBusiness } = usePlan(); - - return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey); -}; - -export default useEnterpriseAccess; diff --git a/apps/client/src/ee/hooks/use-feature.ts b/apps/client/src/ee/hooks/use-feature.ts new file mode 100644 index 00000000..5521477c --- /dev/null +++ b/apps/client/src/ee/hooks/use-feature.ts @@ -0,0 +1,7 @@ +import { useAtom } from "jotai"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; + +export const useHasFeature = (feature: string): boolean => { + const [entitlements] = useAtom(entitlementAtom); + return entitlements?.features?.includes(feature) ?? false; +}; diff --git a/apps/client/src/ee/hooks/use-license.tsx b/apps/client/src/ee/hooks/use-license.tsx deleted file mode 100644 index e3f72d82..00000000 --- a/apps/client/src/ee/hooks/use-license.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useAtom } from "jotai"; -import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; - -export const useLicense = () => { - const [currentUser] = useAtom(currentUserAtom); - return { hasLicenseKey: currentUser?.workspace?.hasLicenseKey }; -}; - -export default useLicense; diff --git a/apps/client/src/ee/hooks/use-upgrade-label.ts b/apps/client/src/ee/hooks/use-upgrade-label.ts new file mode 100644 index 00000000..22253c7b --- /dev/null +++ b/apps/client/src/ee/hooks/use-upgrade-label.ts @@ -0,0 +1,16 @@ +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; +import { isCloud } from "@/lib/config"; + +export function useUpgradeLabel(): string { + const { t } = useTranslation(); + const [entitlements] = useAtom(entitlementAtom); + + if (!isCloud()) { + return entitlements != null && entitlements.tier !== "free" + ? t("Upgrade your license tier.") + : t("Available with a paid license"); + } + return t("Upgrade your plan"); +} diff --git a/apps/client/src/ee/licence/components/activate-license-modal.tsx b/apps/client/src/ee/licence/components/activate-license-modal.tsx index d9f68b22..28b3d0d6 100644 --- a/apps/client/src/ee/licence/components/activate-license-modal.tsx +++ b/apps/client/src/ee/licence/components/activate-license-modal.tsx @@ -7,21 +7,22 @@ import { useTranslation } from "react-i18next"; import { useActivateMutation } from "@/ee/licence/queries/license-query.ts"; import { useDisclosure } from "@mantine/hooks"; import { useAtom } from "jotai"; -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; import RemoveLicense from "@/ee/licence/components/remove-license.tsx"; export default function ActivateLicense() { const { t } = useTranslation(); const [opened, { open, close }] = useDisclosure(false); - const [workspace] = useAtom(workspaceAtom); + const [entitlements] = useAtom(entitlementAtom); + const hasLicense = entitlements != null && entitlements.tier !== "free"; return ( - {workspace?.hasLicenseKey && } + {hasLicense && } Edition - Enterprise {license.trial && Trial} + {license.licenseType === "business" ? "Business" : "Enterprise"}{" "} + {license.trial && Trial} diff --git a/apps/client/src/ee/licence/pages/license.tsx b/apps/client/src/ee/licence/pages/license.tsx index f8d685c1..0aa9d2f5 100644 --- a/apps/client/src/ee/licence/pages/license.tsx +++ b/apps/client/src/ee/licence/pages/license.tsx @@ -8,10 +8,11 @@ import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal. import InstallationDetails from "@/ee/licence/components/installation-details.tsx"; import OssDetails from "@/ee/licence/components/oss-details.tsx"; import { useAtom } from "jotai/index"; -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; export default function License() { - const [workspace] = useAtom(workspaceAtom); + const [entitlements] = useAtom(entitlementAtom); + const hasLicense = entitlements != null && entitlements.tier !== "free"; const { isAdmin } = useUserRole(); if (!isAdmin) { @@ -29,7 +30,7 @@ export default function License() { - {workspace?.hasLicenseKey ? : } + {hasLicense ? : } ); } diff --git a/apps/client/src/ee/licence/queries/license-query.ts b/apps/client/src/ee/licence/queries/license-query.ts index 90e74304..07f1d7e8 100644 --- a/apps/client/src/ee/licence/queries/license-query.ts +++ b/apps/client/src/ee/licence/queries/license-query.ts @@ -31,6 +31,7 @@ export function useActivateMutation() { queryKey: ["license"], }); queryClient.refetchQueries({ queryKey: ["currentUser"] }); + queryClient.refetchQueries({ queryKey: ["entitlements"] }); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; @@ -47,6 +48,7 @@ export function useRemoveLicenseMutation() { onSuccess: () => { queryClient.refetchQueries({ queryKey: ["license"] }); queryClient.refetchQueries({ queryKey: ["currentUser"] }); + queryClient.refetchQueries({ queryKey: ["entitlements"] }); }, }); } diff --git a/apps/client/src/ee/licence/types/license.types.ts b/apps/client/src/ee/licence/types/license.types.ts index e0493a64..ec3c9a18 100644 --- a/apps/client/src/ee/licence/types/license.types.ts +++ b/apps/client/src/ee/licence/types/license.types.ts @@ -1,7 +1,10 @@ +export type LicenseType = 'business' | 'enterprise'; + export interface ILicenseInfo { id: string; customerName: string; seatCount: number; + licenseType: LicenseType; issuedAt: Date; expiresAt: Date; trial: boolean; diff --git a/apps/client/src/ee/mfa/components/mfa-settings.tsx b/apps/client/src/ee/mfa/components/mfa-settings.tsx index 73d9247d..620d67ac 100644 --- a/apps/client/src/ee/mfa/components/mfa-settings.tsx +++ b/apps/client/src/ee/mfa/components/mfa-settings.tsx @@ -7,8 +7,9 @@ import { getMfaStatus } from "@/ee/mfa"; import { MfaSetupModal } from "@/ee/mfa"; import { MfaDisableModal } from "@/ee/mfa"; import { MfaBackupCodesModal } from "@/ee/mfa"; -import { isCloud } from "@/lib/config.ts"; -import useLicense from "@/ee/hooks/use-license.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row"; export function MfaSettings() { @@ -17,7 +18,8 @@ export function MfaSettings() { const [setupModalOpen, setSetupModalOpen] = useState(false); const [disableModalOpen, setDisableModalOpen] = useState(false); const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false); - const { hasLicenseKey } = useLicense(); + const canUseMfa = useHasFeature(Feature.MFA); + const upgradeLabel = useUpgradeLabel(); const { data: mfaStatus, isLoading } = useQuery({ queryKey: ["mfa-status"], @@ -28,8 +30,6 @@ export function MfaSettings() { return null; } - const canUseMfa = isCloud() || hasLicenseKey; - // Check if MFA is truly enabled const isMfaEnabled = mfaStatus?.isEnabled === true; @@ -69,7 +69,7 @@ export function MfaSettings() { {!isMfaEnabled ? (