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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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: