From 27a9c0ebe44f1989397abdbd148efa61cbd9901a Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:55:09 -0700 Subject: [PATCH 01/24] 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 e1641221..7dcbb146 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit e1641221e4605714a0946b0de6bfaa9d7ae64007 +Subproject commit 7dcbb146b3946f11e483cec6ecb85f61e6a06b22 From ac79a185defc1a4ab7167cd24635a7ff02b5bc73 Mon Sep 17 00:00:00 2001 From: Finn Dittmar <86927734+Vito0912@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:13:21 +0200 Subject: [PATCH 02/24] fix ctrl-a for codeblocks (#1336) --- .../editor-ext/src/lib/custom-code-block.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/editor-ext/src/lib/custom-code-block.ts b/packages/editor-ext/src/lib/custom-code-block.ts index 094b8b9a..702e98a9 100644 --- a/packages/editor-ext/src/lib/custom-code-block.ts +++ b/packages/editor-ext/src/lib/custom-code-block.ts @@ -35,6 +35,42 @@ export const CustomCodeBlock = CodeBlockLowlight.extend( return true; } }, + "Mod-a": () => { + if (this.editor.isActive("codeBlock")) { + const { state } = this.editor; + const { $from } = state.selection; + + let codeBlockNode = null; + let codeBlockPos = null; + let depth = 0; + + for (depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if (node.type.name === "codeBlock") { + codeBlockNode = node; + codeBlockPos = $from.start(depth) - 1; + break; + } + } + + if (codeBlockNode && codeBlockPos !== null) { + const codeBlockStart = codeBlockPos; + const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize; + + const contentStart = codeBlockStart + 1; + const contentEnd = codeBlockEnd - 1; + + this.editor.commands.setTextSelection({ + from: contentStart, + to: contentEnd, + }); + + return true; + } + } + + return false; + }, }; }, From f80004817c36970f05500b1a402f08deed7b650c Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:05:34 -0700 Subject: [PATCH 03/24] 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 7dcbb146..4c252d1e 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 7dcbb146b3946f11e483cec6ecb85f61e6a06b22 +Subproject commit 4c252d1ec35a3fb13c8eaf19509de83cf5fe2779 From 29388636bf9e0b8b90c5d3ca3159fe74460a6106 Mon Sep 17 00:00:00 2001 From: fuscodev Date: Thu, 10 Jul 2025 05:40:07 +0200 Subject: [PATCH 04/24] feat: find and replace in editor (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: page find and replace * * Refactor search and replace directory * bugfix scroll * Fix search and replace functionality for macOS and improve UX - Fixed cmd+f shortcut to work on macOS (using 'Mod' key instead of 'Control') - Added search functionality to title editor - Fixed "Not found" message showing when search term is empty - Fixed tooltip error when clicking replace button - Changed replace button from icon to text for consistency - Reduced width of search input fields for better UI - Fixed result index after replace operation to prevent out-of-bounds error - Added missing translation strings for search and replace dialog - Updated tooltip to show platform-specific shortcuts (⌘F on Mac, Ctrl-F on others) * Hide replace functionality for users with view-only permissions - Added editable prop to SearchAndReplaceDialog component - Pass editable state from PageEditor to SearchAndReplaceDialog - Conditionally render replace button based on edit permissions - Hide replace input section for view-only users - Disable Alt+R shortcut when user lacks edit permissions * Fix search dialog not closing properly when navigating away - Clear all state (search text, replace text) when closing dialog - Reset replace button visibility state on close - Clear editor search term to remove highlights - Ensure dialog closes properly when route changes * fix: preserve text marks (comments, etc.) when replacing text in search and replace - Collect all marks that span the text being replaced using nodesBetween - Apply collected marks to the replacement text to maintain formatting - Fixes issue where comment marks were being removed during text replacement * ignore type error --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com> --- .../public/locales/en-US/translation.json | 12 +- .../atoms/search-and-replace-state-atom.ts | 9 + .../search-and-replace-dialog.tsx | 312 ++++++++++++ .../search-replace.module.css | 10 + .../features/editor/extensions/extensions.ts | 17 + .../src/features/editor/page-editor.tsx | 3 + .../src/features/editor/styles/details.css | 10 +- .../src/features/editor/styles/find.css | 9 + .../src/features/editor/styles/index.css | 2 +- .../src/features/editor/title-editor.tsx | 44 +- .../components/header/page-header-menu.tsx | 29 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/search-and-replace/index.ts | 3 + .../search-and-replace/search-and-replace.ts | 455 ++++++++++++++++++ 14 files changed, 903 insertions(+), 13 deletions(-) create mode 100644 apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts create mode 100644 apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx create mode 100644 apps/client/src/features/editor/components/search-and-replace/search-replace.module.css create mode 100644 apps/client/src/features/editor/styles/find.css create mode 100644 packages/editor-ext/src/lib/search-and-replace/index.ts create mode 100644 packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index b9287dc9..769ff61a 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -389,5 +389,15 @@ "Failed to share page": "Failed to share page", "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", - "Page copied successfully": "Page copied successfully" + "Page copied successfully": "Page copied successfully", + "Find": "Find", + "Not found": "Not found", + "Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)", + "Next match (Enter)": "Next match (Enter)", + "Match case (Alt+C)": "Match case (Alt+C)", + "Replace": "Replace", + "Close (Escape)": "Close (Escape)", + "Replace (Enter)": "Replace (Enter)", + "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", + "Replace all": "Replace all" } diff --git a/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts b/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts new file mode 100644 index 00000000..e9760ef3 --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts @@ -0,0 +1,9 @@ +import { atom } from "jotai"; + +type SearchAndReplaceAtomType = { + isOpen: boolean; +}; + +export const searchAndReplaceStateAtom = atom({ + isOpen: false, +}); diff --git a/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx new file mode 100644 index 00000000..df6f0031 --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/search-and-replace-dialog.tsx @@ -0,0 +1,312 @@ +import { + ActionIcon, + Button, + Dialog, + Flex, + Input, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { + IconArrowNarrowDown, + IconArrowNarrowUp, + IconLetterCase, + IconReplace, + IconSearch, + IconX, +} from "@tabler/icons-react"; +import { useEditor } from "@tiptap/react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { getHotkeyHandler, useToggle } from "@mantine/hooks"; +import { useLocation } from "react-router-dom"; +import classes from "./search-replace.module.css"; + +interface PageFindDialogDialogProps { + editor: ReturnType; + editable?: boolean; +} + +function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialogProps) { + const { t } = useTranslation(); + const [searchText, setSearchText] = useState(""); + const [replaceText, setReplaceText] = useState(""); + const [pageFindState, setPageFindState] = useAtom(searchAndReplaceStateAtom); + const inputRef = useRef(null); + + const [replaceButton, replaceButtonToggle] = useToggle([ + { isReplaceShow: false, color: "gray" }, + { isReplaceShow: true, color: "blue" }, + ]); + + const [caseSensitive, caseSensitiveToggle] = useToggle([ + { isCaseSensitive: false, color: "gray" }, + { isCaseSensitive: true, color: "blue" }, + ]); + + const searchInputEvent = (event: React.ChangeEvent) => { + setSearchText(event.target.value); + }; + + const replaceInputEvent = (event: React.ChangeEvent) => { + setReplaceText(event.target.value); + }; + + const closeDialog = () => { + setSearchText(""); + setReplaceText(""); + setPageFindState({ isOpen: false }); + // Reset replace button state when closing + if (replaceButton.isReplaceShow) { + replaceButtonToggle(); + } + // Clear search term in editor + if (editor) { + editor.commands.setSearchTerm(""); + } + }; + + const goToSelection = () => { + if (!editor) return; + + const { results, resultIndex } = editor.storage.searchAndReplace; + const position: Range = results[resultIndex]; + + if (!position) return; + + // @ts-ignore + editor.commands.setTextSelection(position); + + const element = document.querySelector(".search-result-current"); + if (element) + element.scrollIntoView({ behavior: "smooth", block: "center" }); + + editor.commands.setTextSelection(0); + }; + + const next = () => { + editor.commands.nextSearchResult(); + goToSelection(); + }; + + const previous = () => { + editor.commands.previousSearchResult(); + goToSelection(); + }; + + const replace = () => { + editor.commands.setReplaceTerm(replaceText); + editor.commands.replace(); + goToSelection(); + }; + + const replaceAll = () => { + editor.commands.setReplaceTerm(replaceText); + editor.commands.replaceAll(); + }; + + useEffect(() => { + editor.commands.setSearchTerm(searchText); + editor.commands.resetIndex(); + editor.commands.selectCurrentItem(); + }, [searchText]); + + const handleOpenEvent = (e) => { + setPageFindState({ isOpen: true }); + const selectedText = editor.state.doc.textBetween( + editor.state.selection.from, + editor.state.selection.to, + ); + if (selectedText !== "") { + setSearchText(selectedText); + } + inputRef.current?.focus(); + inputRef.current?.select(); + }; + + const handleCloseEvent = (e) => { + closeDialog(); + }; + + useEffect(() => { + !pageFindState.isOpen && closeDialog(); + + document.addEventListener("openFindDialogFromEditor", handleOpenEvent); + document.addEventListener("closeFindDialogFromEditor", handleCloseEvent); + + return () => { + document.removeEventListener("openFindDialogFromEditor", handleOpenEvent); + document.removeEventListener( + "closeFindDialogFromEditor", + handleCloseEvent, + ); + }; + }, [pageFindState.isOpen]); + + useEffect(() => { + editor.commands.setCaseSensitive(caseSensitive.isCaseSensitive); + editor.commands.resetIndex(); + goToSelection(); + }, [caseSensitive]); + + const resultsCount = useMemo( + () => + searchText.trim() === "" + ? "" + : editor?.storage?.searchAndReplace?.results.length > 0 + ? editor?.storage?.searchAndReplace?.resultIndex + + 1 + + "/" + + editor?.storage?.searchAndReplace?.results.length + : t("Not found"), + [ + searchText, + editor?.storage?.searchAndReplace?.resultIndex, + editor?.storage?.searchAndReplace?.results.length, + ], + ); + + const location = useLocation(); + useEffect(() => { + closeDialog(); + }, [location]); + + return ( + + + + } + rightSection={ + + {resultsCount} + + } + rightSectionWidth="70" + rightSectionPointerEvents="all" + size="xs" + w={220} + onChange={searchInputEvent} + value={searchText} + autoFocus + onKeyDown={getHotkeyHandler([ + ["Enter", next], + ["shift+Enter", previous], + ["alt+C", caseSensitiveToggle], + //@ts-ignore + ...(editable ? [["alt+R", replaceButtonToggle]] : []), + ])} + /> + + + + + + + + + + + + + + caseSensitiveToggle()} + > + + + + {editable && ( + + replaceButtonToggle()} + > + + + + )} + + + + + + + + {replaceButton.isReplaceShow && editable && ( + + } + rightSection={
} + rightSectionPointerEvents="all" + size="xs" + w={180} + autoFocus + onChange={replaceInputEvent} + value={replaceText} + onKeyDown={getHotkeyHandler([ + ["Enter", replace], + ["ctrl+alt+Enter", replaceAll], + ])} + /> + + + + + + + + +
+ )} +
+
+ ); +} + +export default SearchAndReplaceDialog; diff --git a/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css b/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css new file mode 100644 index 00000000..f864991c --- /dev/null +++ b/apps/client/src/features/editor/components/search-and-replace/search-replace.module.css @@ -0,0 +1,10 @@ +.findDialog{ + @media print { + display: none; + } +} + +.findDialog div[data-position="right"].mantine-Input-section { + justify-content: right; + padding-right: 8px; +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 96b520ad..7b83fd31 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -36,6 +36,7 @@ import { Drawio, Excalidraw, Embed, + SearchAndReplace, Mention, } from "@docmost/editor-ext"; import { @@ -217,6 +218,22 @@ export const mainExtensions = [ CharacterCount.configure({ wordCounter: (text) => countWords(text), }), + SearchAndReplace.extend({ + addKeyboardShortcuts() { + return { + 'Mod-f': () => { + const event = new CustomEvent("openFindDialogFromEditor", {}); + document.dispatchEvent(event); + return true; + }, + 'Escape': () => { + const event = new CustomEvent("closeFindDialogFromEditor", {}); + document.dispatchEvent(event); + return true; + }, + } + }, + }).configure(), ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index ec937ff9..0114a687 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -44,6 +44,7 @@ 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"; +import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx"; import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useIdle } from "@/hooks/use-idle.ts"; import { queryClient } from "@/main.tsx"; @@ -350,6 +351,8 @@ export default function PageEditor({
+ + {editor && editor.isEditable && (
diff --git a/apps/client/src/features/editor/styles/details.css b/apps/client/src/features/editor/styles/details.css index 567118b8..5c5d151b 100644 --- a/apps/client/src/features/editor/styles/details.css +++ b/apps/client/src/features/editor/styles/details.css @@ -71,4 +71,12 @@ [data-type="details"][open] > [data-type="detailsButton"] .ProseMirror-icon{ transform: rotateZ(90deg); } -} \ No newline at end of file + + [data-type="details"]:has(.search-result) > [data-type="detailsContainer"] > [data-type="detailsContent"]{ + display: block; + } + + [data-type="details"]:has(.search-result) > [data-type="detailsButton"] .ProseMirror-icon{ + transform: rotateZ(90deg); + } +} diff --git a/apps/client/src/features/editor/styles/find.css b/apps/client/src/features/editor/styles/find.css new file mode 100644 index 00000000..77b72f25 --- /dev/null +++ b/apps/client/src/features/editor/styles/find.css @@ -0,0 +1,9 @@ +.search-result{ + background: #ffff65; + color: #212529; +} + +.search-result-current{ + background: #ffc266 !important; + color: #212529; +} diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index cf979957..44793724 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -9,5 +9,5 @@ @import "./media.css"; @import "./code.css"; @import "./print.css"; +@import "./find.css"; @import "./mention.css"; - diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index e695867f..937ae374 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -10,8 +10,11 @@ import { pageEditorAtom, titleEditorAtom, } from "@/features/editor/atoms/editor-atoms"; -import { updatePageData, useUpdateTitlePageMutation } from "@/features/page/queries/page-query"; -import { useDebouncedCallback } from "@mantine/hooks"; +import { + updatePageData, + useUpdateTitlePageMutation, +} from "@/features/page/queries/page-query"; +import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks"; import { useAtom } from "jotai"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { History } from "@tiptap/extension-history"; @@ -40,7 +43,8 @@ export function TitleEditor({ editable, }: TitleEditorProps) { const { t } = useTranslation(); - const { mutateAsync: updateTitlePageMutationAsync } = useUpdateTitlePageMutation(); + const { mutateAsync: updateTitlePageMutationAsync } = + useUpdateTitlePageMutation(); const pageEditor = useAtomValue(pageEditorAtom); const [, setTitleEditor] = useAtom(titleEditorAtom); const emit = useQueryEmit(); @@ -108,7 +112,12 @@ export function TitleEditor({ spaceId: page.spaceId, entity: ["pages"], id: page.id, - payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon }, + payload: { + title: page.title, + slugId: page.slugId, + parentPageId: page.parentPageId, + icon: page.icon, + }, }; if (page.title !== titleEditor.getText()) return; @@ -152,13 +161,19 @@ export function TitleEditor({ } }, [userPageEditMode, titleEditor, editable]); + const openSearchDialog = () => { + const event = new CustomEvent("openFindDialogFromEditor", {}); + document.dispatchEvent(event); + }; + function handleTitleKeyDown(event: any) { if (!titleEditor || !pageEditor || event.shiftKey) return; - - // Prevent focus shift when IME composition is active + + // Prevent focus shift when IME composition is active // `keyCode === 229` is added to support Safari where `isComposing` may not be reliable - if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; - + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) + return; + const { key } = event; const { $head } = titleEditor.state.selection; @@ -172,5 +187,16 @@ export function TitleEditor({ } } - return ; + return ( + { + // First handle the search hotkey + getHotkeyHandler([["mod+F", openSearchDialog]])(event); + + // Then handle other key events + handleTitleKeyDown(event); + }} + /> + ); } diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 816cc502..934be3af 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -9,6 +9,7 @@ import { IconList, IconMessage, IconPrinter, + IconSearch, IconTrash, IconWifiOff, } from "@tabler/icons-react"; @@ -16,7 +17,12 @@ import React from "react"; import useToggleAside from "@/hooks/use-toggle-aside.tsx"; import { useAtom } from "jotai"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; -import { useClipboard, useDisclosure } from "@mantine/hooks"; +import { + getHotkeyHandler, + useClipboard, + useDisclosure, + useHotkeys, +} from "@mantine/hooks"; import { useParams } from "react-router-dom"; import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts"; @@ -32,6 +38,7 @@ import { pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms.ts"; +import { searchAndReplaceStateAtom } from "@/features/editor/components/search-and-replace/atoms/search-and-replace-state-atom.ts"; import { formattedDate, timeAgo } from "@/lib/time.ts"; import { PageStateSegmentedControl } from "@/features/user/components/page-state-pref.tsx"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; @@ -46,6 +53,26 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { const toggleAside = useToggleAside(); const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom); + useHotkeys( + [ + [ + "mod+F", + () => { + const event = new CustomEvent("openFindDialogFromEditor", {}); + document.dispatchEvent(event); + }, + ], + [ + "Escape", + () => { + const event = new CustomEvent("closeFindDialogFromEditor", {}); + document.dispatchEvent(event); + }, + ], + ], + [], + ); + return ( <> {yjsConnectionStatus === "disconnected" && ( diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index f2cb776b..d3e1d53d 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -17,4 +17,5 @@ export * from "./lib/excalidraw"; export * from "./lib/embed"; export * from "./lib/mention"; export * from "./lib/markdown"; +export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; diff --git a/packages/editor-ext/src/lib/search-and-replace/index.ts b/packages/editor-ext/src/lib/search-and-replace/index.ts new file mode 100644 index 00000000..d082e4f8 --- /dev/null +++ b/packages/editor-ext/src/lib/search-and-replace/index.ts @@ -0,0 +1,3 @@ +import { SearchAndReplace } from './search-and-replace' +export * from './search-and-replace' +export default SearchAndReplace \ No newline at end of file diff --git a/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts new file mode 100644 index 00000000..ca66958f --- /dev/null +++ b/packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts @@ -0,0 +1,455 @@ +/*** + MIT License + Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade) + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + ***/ + +import { Extension, Range, type Dispatch } from "@tiptap/core"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + Plugin, + PluginKey, + type EditorState, + type Transaction, +} from "@tiptap/pm/state"; +import { Node as PMNode, Mark } from "@tiptap/pm/model"; + +declare module "@tiptap/core" { + interface Commands { + search: { + /** + * @description Set search term in extension. + */ + setSearchTerm: (searchTerm: string) => ReturnType; + /** + * @description Set replace term in extension. + */ + setReplaceTerm: (replaceTerm: string) => ReturnType; + /** + * @description Set case sensitivity in extension. + */ + setCaseSensitive: (caseSensitive: boolean) => ReturnType; + /** + * @description Reset current search result to first instance. + */ + resetIndex: () => ReturnType; + /** + * @description Find next instance of search result. + */ + nextSearchResult: () => ReturnType; + /** + * @description Find previous instance of search result. + */ + previousSearchResult: () => ReturnType; + /** + * @description Replace first instance of search result with given replace term. + */ + replace: () => ReturnType; + /** + * @description Replace all instances of search result with given replace term. + */ + replaceAll: () => ReturnType; + /** + * @description Find selected instance of search result. + */ + selectCurrentItem: () => ReturnType; + }; + } +} + +interface TextNodesWithPosition { + text: string; + pos: number; +} + +const getRegex = ( + s: string, + disableRegex: boolean, + caseSensitive: boolean, +): RegExp => { + return RegExp( + disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : s, + caseSensitive ? "gu" : "gui", + ); +}; + +interface ProcessedSearches { + decorationsToReturn: DecorationSet; + results: Range[]; +} + +function processSearches( + doc: PMNode, + searchTerm: RegExp, + searchResultClass: string, + resultIndex: number, +): ProcessedSearches { + const decorations: Decoration[] = []; + const results: Range[] = []; + + let textNodesWithPosition: TextNodesWithPosition[] = []; + let index = 0; + + if (!searchTerm) { + return { + decorationsToReturn: DecorationSet.empty, + results: [], + }; + } + + doc?.descendants((node, pos) => { + if (node.isText) { + if (textNodesWithPosition[index]) { + textNodesWithPosition[index] = { + text: textNodesWithPosition[index].text + node.text, + pos: textNodesWithPosition[index].pos, + }; + } else { + textNodesWithPosition[index] = { + text: `${node.text}`, + pos, + }; + } + } else { + index += 1; + } + }); + + textNodesWithPosition = textNodesWithPosition.filter(Boolean); + + for (const element of textNodesWithPosition) { + const { text, pos } = element; + const matches = Array.from(text.matchAll(searchTerm)).filter( + ([matchText]) => matchText.trim(), + ); + + for (const m of matches) { + if (m[0] === "") break; + + if (m.index !== undefined) { + results.push({ + from: pos + m.index, + to: pos + m.index + m[0].length, + }); + } + } + } + + for (let i = 0; i < results.length; i += 1) { + const r = results[i]; + const className = + i === resultIndex + ? `${searchResultClass} ${searchResultClass}-current` + : searchResultClass; + const decoration: Decoration = Decoration.inline(r.from, r.to, { + class: className, + }); + + decorations.push(decoration); + } + + return { + decorationsToReturn: DecorationSet.create(doc, decorations), + results, + }; +} + +const replace = ( + replaceTerm: string, + results: Range[], + resultIndex: number, + { state, dispatch }: { state: EditorState; dispatch: Dispatch }, +) => { + const firstResult = results[resultIndex]; + + if (!firstResult) return; + + const { from, to } = results[resultIndex]; + + if (dispatch) { + const tr = state.tr; + + // Get all marks that span the text being replaced + const marksSet = new Set(); + state.doc.nodesBetween(from, to, (node) => { + if (node.isText && node.marks) { + node.marks.forEach(mark => marksSet.add(mark)); + } + }); + + const marks = Array.from(marksSet); + + // Delete the old text and insert new text with preserved marks + tr.delete(from, to); + tr.insert(from, state.schema.text(replaceTerm, marks)); + + dispatch(tr); + } +}; + +const replaceAll = ( + replaceTerm: string, + results: Range[], + { tr, dispatch }: { tr: Transaction; dispatch: Dispatch }, +) => { + const resultsCopy = results.slice(); + + if (!resultsCopy.length) return; + + // Process replacements in reverse order to avoid position shifting issues + for (let i = resultsCopy.length - 1; i >= 0; i -= 1) { + const { from, to } = resultsCopy[i]; + + // Get all marks that span the text being replaced + const marksSet = new Set(); + tr.doc.nodesBetween(from, to, (node) => { + if (node.isText && node.marks) { + node.marks.forEach(mark => marksSet.add(mark)); + } + }); + + const marks = Array.from(marksSet); + + // Delete and insert with preserved marks + tr.delete(from, to); + tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks)); + } + + dispatch(tr); +}; + +export const searchAndReplacePluginKey = new PluginKey( + "searchAndReplacePlugin", +); + +export interface SearchAndReplaceOptions { + searchResultClass: string; + disableRegex: boolean; +} + +export interface SearchAndReplaceStorage { + searchTerm: string; + replaceTerm: string; + results: Range[]; + lastSearchTerm: string; + caseSensitive: boolean; + lastCaseSensitive: boolean; + resultIndex: number; + lastResultIndex: number; +} + +export const SearchAndReplace = Extension.create< + SearchAndReplaceOptions, + SearchAndReplaceStorage +>({ + name: "searchAndReplace", + + addOptions() { + return { + searchResultClass: "search-result", + disableRegex: true, + }; + }, + + addStorage() { + return { + searchTerm: "", + replaceTerm: "", + results: [], + lastSearchTerm: "", + caseSensitive: false, + lastCaseSensitive: false, + resultIndex: 0, + lastResultIndex: 0, + }; + }, + + addCommands() { + return { + setSearchTerm: + (searchTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.searchTerm = searchTerm; + + return false; + }, + setReplaceTerm: + (replaceTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.replaceTerm = replaceTerm; + + return false; + }, + setCaseSensitive: + (caseSensitive: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.caseSensitive = caseSensitive; + + return false; + }, + resetIndex: + () => + ({ editor }) => { + editor.storage.searchAndReplace.resultIndex = 0; + + return false; + }, + nextSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; + + const nextIndex = resultIndex + 1; + + if (results[nextIndex]) { + editor.storage.searchAndReplace.resultIndex = nextIndex; + } else { + editor.storage.searchAndReplace.resultIndex = 0; + } + + return false; + }, + previousSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; + + const prevIndex = resultIndex - 1; + + if (results[prevIndex]) { + editor.storage.searchAndReplace.resultIndex = prevIndex; + } else { + editor.storage.searchAndReplace.resultIndex = results.length - 1; + } + + return false; + }, + replace: + () => + ({ editor, state, dispatch }) => { + const { replaceTerm, results, resultIndex } = + editor.storage.searchAndReplace; + + replace(replaceTerm, results, resultIndex, { state, dispatch }); + + // After replace, adjust index if needed + // The results will be recalculated by the plugin, but we need to ensure + // the index doesn't exceed the new bounds + setTimeout(() => { + const newResultsLength = editor.storage.searchAndReplace.results.length; + if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) { + // Keep the same position if possible, otherwise go to the last result + editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1); + } + }, 0); + + return false; + }, + replaceAll: + () => + ({ editor, tr, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replaceAll(replaceTerm, results, { tr, dispatch }); + + return false; + }, + selectCurrentItem: + () => + ({ editor }) => { + const { results } = editor.storage.searchAndReplace; + for (let i = 0; i < results.length; i++) { + if ( + results[i].from == editor.state.selection.from && + results[i].to == editor.state.selection.to + ) { + editor.storage.searchAndReplace.resultIndex = i; + } + } + return false; + }, + }; + }, + + addProseMirrorPlugins() { + const editor = this.editor; + const { searchResultClass, disableRegex } = this.options; + + const setLastSearchTerm = (t: string) => + (editor.storage.searchAndReplace.lastSearchTerm = t); + const setLastCaseSensitive = (t: boolean) => + (editor.storage.searchAndReplace.lastCaseSensitive = t); + const setLastResultIndex = (t: number) => + (editor.storage.searchAndReplace.lastResultIndex = t); + + return [ + new Plugin({ + key: searchAndReplacePluginKey, + state: { + init: () => DecorationSet.empty, + apply({ doc, docChanged }, oldState) { + const { + searchTerm, + lastSearchTerm, + caseSensitive, + lastCaseSensitive, + resultIndex, + lastResultIndex, + } = editor.storage.searchAndReplace; + + if ( + !docChanged && + lastSearchTerm === searchTerm && + lastCaseSensitive === caseSensitive && + lastResultIndex === resultIndex + ) + return oldState; + + setLastSearchTerm(searchTerm); + setLastCaseSensitive(caseSensitive); + setLastResultIndex(resultIndex); + + if (!searchTerm) { + editor.storage.searchAndReplace.results = []; + return DecorationSet.empty; + } + + const { decorationsToReturn, results } = processSearches( + doc, + getRegex(searchTerm, disableRegex, caseSensitive), + searchResultClass, + resultIndex, + ); + + editor.storage.searchAndReplace.results = results; + + return decorationsToReturn; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }), + ]; + }, +}); + +export default SearchAndReplace; From 9fa2b9636cd9ba21ad88a5de6779e38be8d58a8d Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 13 Jul 2025 15:38:29 -0700 Subject: [PATCH 05/24] make sure editor is ready for editor search --- apps/client/src/features/editor/page-editor.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 0114a687..c8ab04a4 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -1,10 +1,5 @@ import "@/features/editor/styles/index.css"; -import React, { - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; import { @@ -351,7 +346,10 @@ export default function PageEditor({
- + + {editor && ( + + )} {editor && editor.isEditable && (
From c2c165528b312cc70a3d142110532055b04e8e1f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sun, 13 Jul 2025 23:19:06 -0700 Subject: [PATCH 06/24] fix: seamlessly update editor collab token on expiration (#1366) --- .../src/features/editor/page-editor.tsx | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index c8ab04a4..9de17de2 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -126,7 +126,15 @@ export default function PageEditor({ const now = Date.now().valueOf() / 1000; const isTokenExpired = now >= payload.exp; if (isTokenExpired) { - refetchCollabToken(); + refetchCollabToken().then((result) => { + if (result.data?.token) { + remote.disconnect(); + setTimeout(() => { + remote.configuration.token = result.data.token; + remote.connect(); + }, 100); + } + }); } }, onStatus: (status) => { @@ -152,6 +160,21 @@ export default function PageEditor({ }; }, [pageId]); + /* + useEffect(() => { + // Handle token updates by reconnecting with new token + if (providersRef.current?.remote && collabQuery?.token) { + const currentToken = providersRef.current.remote.configuration.token; + if (currentToken !== collabQuery.token) { + // Token has changed, need to reconnect with new token + providersRef.current.remote.disconnect(); + providersRef.current.remote.configuration.token = collabQuery.token; + providersRef.current.remote.connect(); + } + } + }, [collabQuery?.token]); + */ + // Only connect/disconnect on tab/idle, not destroy useEffect(() => { if (!providersReady || !providersRef.current) return; From e856c8eb691e67333bbf6b22dfd5c5d16c8b0c30 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 14 Jul 2025 02:34:18 -0700 Subject: [PATCH 07/24] (cloud) fix: updates to billing (#1367) * billing updates (cloud) * old billing grace period --- .../ee/billing/components/billing-details.tsx | 5 +- .../ee/billing/components/billing-plans.tsx | 117 ++++++++++++------ .../src/ee/billing/types/billing.types.ts | 2 +- apps/server/src/ee | 2 +- 4 files changed, 87 insertions(+), 39 deletions(-) diff --git a/apps/client/src/ee/billing/components/billing-details.tsx b/apps/client/src/ee/billing/components/billing-details.tsx index a4ea9547..0fb06147 100644 --- a/apps/client/src/ee/billing/components/billing-details.tsx +++ b/apps/client/src/ee/billing/components/billing-details.tsx @@ -117,7 +117,8 @@ export default function BillingDetails() { {billing.billingScheme === "tiered" && ( <> - ${billing.amount / 100} {billing.currency.toUpperCase()} + ${billing.amount / 100} {billing.currency.toUpperCase()} /{" "} + {billing.interval} per {billing.interval} @@ -129,7 +130,7 @@ export default function BillingDetails() { <> {(billing.amount / 100) * billing.quantity}{" "} - {billing.currency.toUpperCase()} + {billing.currency.toUpperCase()} / {billing.interval} ${billing.amount / 100} /user/{billing.interval} diff --git a/apps/client/src/ee/billing/components/billing-plans.tsx b/apps/client/src/ee/billing/components/billing-plans.tsx index 8d5f28d3..5bff1485 100644 --- a/apps/client/src/ee/billing/components/billing-plans.tsx +++ b/apps/client/src/ee/billing/components/billing-plans.tsx @@ -12,14 +12,18 @@ import { Badge, Flex, Switch, + Alert, } from "@mantine/core"; import { useState } from "react"; -import { IconCheck } from "@tabler/icons-react"; +import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts"; import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts"; +import { useAtomValue } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; export default function BillingPlans() { const { data: plans } = useBillingPlans(); + const workspace = useAtomValue(workspaceAtom); const [isAnnual, setIsAnnual] = useState(true); const [selectedTierValue, setSelectedTierValue] = useState( null, @@ -36,49 +40,76 @@ export default function BillingPlans() { } }; + // TODO: remove by July 30. + // Check if workspace was created between June 28 and July 14, 2025 + const showTieredPricingNotice = (() => { + if (!workspace?.createdAt) return false; + const createdDate = new Date(workspace.createdAt); + const startDate = new Date('2025-06-20'); + const endDate = new Date('2025-07-14'); + return createdDate >= startDate && createdDate <= endDate; + })(); + if (!plans || plans.length === 0) { return null; } - const firstPlan = plans[0]; + // Check if any plan is tiered + const hasTieredPlans = plans.some(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0); + const firstTieredPlan = plans.find(plan => plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0); - // Set initial tier value if not set - if (!selectedTierValue && firstPlan.pricingTiers.length > 0) { - setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString()); + // Set initial tier value if not set and we have tiered plans + if (hasTieredPlans && !selectedTierValue && firstTieredPlan) { + setSelectedTierValue(firstTieredPlan.pricingTiers[0].upTo.toString()); return null; } - if (!selectedTierValue) { + // For tiered plans, ensure we have a selected tier + if (hasTieredPlans && !selectedTierValue) { return null; } - const selectData = firstPlan.pricingTiers - .filter((tier) => !tier.custom) + const selectData = firstTieredPlan?.pricingTiers + ?.filter((tier) => !tier.custom) .map((tier, index) => { const prevMaxUsers = - index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0; + index > 0 ? firstTieredPlan.pricingTiers[index - 1].upTo : 0; return { value: tier.upTo.toString(), label: `${prevMaxUsers + 1}-${tier.upTo} users`, }; - }); + }) || []; return ( + {/* Tiered pricing notice for eligible workspaces */} + {showTieredPricingNotice && !hasTieredPlans && ( + } + title="Want the old tiered pricing?" + color="blue" + mb="lg" + > + Contact support to switch back to our tiered pricing model. + + )} + {/* Controls Section */} {/* Team Size and Billing Controls */} - + )} @@ -102,17 +133,29 @@ export default function BillingPlans() { {/* Plans Grid */} {plans.map((plan, index) => { - const tieredPlan = plan; - const planSelectedTier = - tieredPlan.pricingTiers.find( - (tier) => tier.upTo.toString() === selectedTierValue, - ) || tieredPlan.pricingTiers[0]; - - const price = isAnnual - ? planSelectedTier.yearly - : planSelectedTier.monthly; + let price; + let displayPrice; const priceId = isAnnual ? plan.yearlyId : plan.monthlyId; + if (plan.billingScheme === 'tiered' && plan.pricingTiers?.length > 0) { + // Tiered billing logic + const planSelectedTier = + plan.pricingTiers.find( + (tier) => tier.upTo.toString() === selectedTierValue, + ) || plan.pricingTiers[0]; + + price = isAnnual + ? planSelectedTier.yearly + : planSelectedTier.monthly; + displayPrice = isAnnual ? (price / 12).toFixed(0) : price; + } else { + // Per-unit billing logic + const monthlyPrice = parseFloat(plan.price?.monthly || '0'); + const yearlyPrice = parseFloat(plan.price?.yearly || '0'); + price = isAnnual ? yearlyPrice : monthlyPrice; + displayPrice = isAnnual ? (yearlyPrice / 12).toFixed(0) : monthlyPrice; + } + return ( - ${isAnnual ? (price / 12).toFixed(0) : price} + ${displayPrice} - per {isAnnual ? "month" : "month"} + {plan.billingScheme === 'per_unit' + ? `per user/month` + : `per month`} {isAnnual && ( @@ -154,14 +199,16 @@ export default function BillingPlans() { Billed annually )} - - For {planSelectedTier.upTo} users - + {plan.billingScheme === 'tiered' && plan.pricingTiers && ( + + For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users + + )} {/* CTA Button */} {/* Features */} diff --git a/apps/client/src/ee/billing/types/billing.types.ts b/apps/client/src/ee/billing/types/billing.types.ts index dfa1a60b..58225519 100644 --- a/apps/client/src/ee/billing/types/billing.types.ts +++ b/apps/client/src/ee/billing/types/billing.types.ts @@ -53,7 +53,7 @@ export interface IBillingPlan { }; features: string[]; billingScheme: string | null; - pricingTiers: PricingTier[]; + pricingTiers?: PricingTier[]; } interface PricingTier { diff --git a/apps/server/src/ee b/apps/server/src/ee index 4c252d1e..49a16ab3 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 4c252d1ec35a3fb13c8eaf19509de83cf5fe2779 +Subproject commit 49a16ab3e03971a375bcbfac60c3c1150d19059b From e51a93221c5e652b561279383ecdf4c08965f23e Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 14 Jul 2025 02:35:03 -0700 Subject: [PATCH 08/24] more checks for collab auth token (#1345) --- .../extensions/authentication.extension.ts | 4 ++++ apps/server/src/core/auth/auth.controller.ts | 2 +- apps/server/src/core/auth/services/auth.service.ts | 6 +++--- apps/server/src/core/auth/services/token.service.ts | 13 +++++++------ 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index b7925619..1a42bd97 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -46,6 +46,10 @@ export class AuthenticationExtension implements Extension { throw new UnauthorizedException(); } + if (user.deactivatedAt || user.deletedAt) { + throw new UnauthorizedException(); + } + const page = await this.pageRepo.findById(pageId); if (!page) { this.logger.warn(`Page not found: ${pageId}`); diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index fb98ed7f..dc1235ec 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -108,7 +108,7 @@ export class AuthController { @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { - return this.authService.getCollabToken(user.id, workspace.id); + return this.authService.getCollabToken(user, workspace.id); } @UseGuards(JwtAuthGuard) diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 9c761ef3..c71bc3bc 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -22,7 +22,7 @@ import { ForgotPasswordDto } from '../dto/forgot-password.dto'; import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email'; import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo'; import { PasswordResetDto } from '../dto/password-reset.dto'; -import { UserToken, Workspace } from '@docmost/db/types/entity.types'; +import { User, UserToken, Workspace } from '@docmost/db/types/entity.types'; import { UserTokenType } from '../auth.constants'; import { KyselyDB } from '@docmost/db/types/kysely.types'; import { InjectKysely } from 'nestjs-kysely'; @@ -222,9 +222,9 @@ export class AuthService { } } - async getCollabToken(userId: string, workspaceId: string) { + async getCollabToken(user: User, workspaceId: string) { const token = await this.tokenService.generateCollabToken( - userId, + user, workspaceId, ); return { token }; diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index 963e8e65..c0e64e25 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -22,7 +22,7 @@ export class TokenService { ) {} async generateAccessToken(user: User): Promise { - if (user.deletedAt) { + if (user.deactivatedAt || user.deletedAt) { throw new ForbiddenException(); } @@ -35,12 +35,13 @@ export class TokenService { return this.jwtService.sign(payload); } - async generateCollabToken( - userId: string, - workspaceId: string, - ): Promise { + async generateCollabToken(user: User, workspaceId: string): Promise { + if (user.deactivatedAt || user.deletedAt) { + throw new ForbiddenException(); + } + const payload: JwtCollabPayload = { - sub: userId, + sub: user.id, workspaceId, type: JwtType.COLLAB, }; From 6792a191b1e569220c60b27a25883f11483cdac7 Mon Sep 17 00:00:00 2001 From: fuscodev Date: Mon, 14 Jul 2025 11:36:24 +0200 Subject: [PATCH 09/24] feat: Ctrl/Cmd+S: prevent 'Save As' dialog (#1272) * init * remove: force save * switch from event.key to event.code by sanua356 --- apps/client/src/features/editor/page-editor.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 9de17de2..01f6dc55 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -217,6 +217,10 @@ export default function PageEditor({ scrollMargin: 80, handleDOMEvents: { keydown: (_view, event) => { + if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') { + event.preventDefault(); + return true; + } if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { From f4082171ecaa86200d87201e83ae517fe88f6df8 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 14 Jul 2025 02:37:13 -0700 Subject: [PATCH 10/24] feat: display user email below name in multi-member-select dropdown (#1355) - Added email field to user items mapping - Updated renderMultiSelectOption to show email in smaller, dimmed text - Email only displays for user type options, not groups --- .../src/features/space/components/multi-member-select.tsx | 4 ++++ apps/server/src/core/search/search.service.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/client/src/features/space/components/multi-member-select.tsx b/apps/client/src/features/space/components/multi-member-select.tsx index efa2142f..602a6232 100644 --- a/apps/client/src/features/space/components/multi-member-select.tsx +++ b/apps/client/src/features/space/components/multi-member-select.tsx @@ -26,6 +26,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ {option["type"] === "group" && }
{option.label} + {option["type"] === "user" && option["email"] && ( + {option["email"]} + )}
); @@ -47,6 +50,7 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) { const userItems = suggestion?.users.map((user: IUser) => ({ value: `user-${user.id}`, label: user.name, + email: user.email, avatarUrl: user.avatarUrl, type: "user", })); diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index b8a62170..3ea1e535 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -140,7 +140,7 @@ export class SearchService { if (suggestion.includeUsers) { users = await this.db .selectFrom('users') - .select(['id', 'name', 'avatarUrl']) + .select(['id', 'name', 'email', 'avatarUrl']) .where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`)) .where('workspaceId', '=', workspaceId) .where('deletedAt', 'is', null) From 5f5f1484dbf9f5617796497d97febd81d11b7e3f Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 14 Jul 2025 03:53:07 -0700 Subject: [PATCH 11/24] throw early --- apps/server/src/core/page/page.controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index f8caeb55..145c5313 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -146,7 +146,6 @@ export class PageController { return this.pageService.getRecentPages(user.id, pagination); } - // TODO: scope to workspaces @HttpCode(HttpStatus.OK) @Post('/history') async getPageHistory( @@ -155,6 +154,10 @@ export class PageController { @AuthUser() user: User, ) { const page = await this.pageRepo.findById(dto.pageId); + if (!page) { + throw new NotFoundException('Page not found'); + } + const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); From 608783b5cf61f7ccf9fcacfd036c0bf851d58d6e Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 14 Jul 2025 03:56:26 -0700 Subject: [PATCH 12/24] (cloud) billing copy --- apps/client/src/ee/billing/components/billing-plans.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/client/src/ee/billing/components/billing-plans.tsx b/apps/client/src/ee/billing/components/billing-plans.tsx index 5bff1485..b57643b2 100644 --- a/apps/client/src/ee/billing/components/billing-plans.tsx +++ b/apps/client/src/ee/billing/components/billing-plans.tsx @@ -194,11 +194,9 @@ export default function BillingPlans() { : `per month`}
- {isAnnual && ( - - Billed annually - - )} + + {isAnnual ? "Billed annually" : "Billed monthly"} + {plan.billingScheme === 'tiered' && plan.pricingTiers && ( For {plan.pricingTiers.find(tier => tier.upTo.toString() === selectedTierValue)?.upTo || plan.pricingTiers[0].upTo} users From 16ec218ba7a057714d0427ac97e95fd3e4f28b10 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 14 Jul 2025 10:28:42 -0700 Subject: [PATCH 13/24] fix: deactivated user check --- apps/server/src/core/auth/strategies/jwt.strategy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index 083444f2..fae56b7c 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -42,7 +42,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { } const user = await this.userRepo.findById(payload.sub, payload.workspaceId); - if (!user || user.deletedAt) { + if (!user || user.deactivatedAt || user.deletedAt) { throw new UnauthorizedException(); } From 9f39987404e39854be91d02e993d6c29edfc313d Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:43:59 +0100 Subject: [PATCH 14/24] fix: nested ordered-list style (#1351) * feat: dynamic ordered-list style * fix nested task list import --- .../src/features/editor/styles/index.css | 1 + .../features/editor/styles/ordered-list.css | 34 +++++++++++++++++++ .../src/collaboration/collaboration.util.ts | 4 ++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/features/editor/styles/ordered-list.css diff --git a/apps/client/src/features/editor/styles/index.css b/apps/client/src/features/editor/styles/index.css index 44793724..e426e0ba 100644 --- a/apps/client/src/features/editor/styles/index.css +++ b/apps/client/src/features/editor/styles/index.css @@ -11,3 +11,4 @@ @import "./print.css"; @import "./find.css"; @import "./mention.css"; +@import "./ordered-list.css"; diff --git a/apps/client/src/features/editor/styles/ordered-list.css b/apps/client/src/features/editor/styles/ordered-list.css new file mode 100644 index 00000000..d3aadb39 --- /dev/null +++ b/apps/client/src/features/editor/styles/ordered-list.css @@ -0,0 +1,34 @@ +/* Ordered list type cycling based on nesting depth */ +ol, +ol ol ol ol, +ol ol ol ol ol ol ol, +ol ol ol ol ol ol ol ol ol ol { + list-style-type: decimal; +} + +ol ol, +ol ol ol ol ol, +ol ol ol ol ol ol ol ol, +ol ol ol ol ol ol ol ol ol ol ol { + list-style-type: lower-alpha; +} + +ol ol ol, +ol ol ol ol ol ol, +ol ol ol ol ol ol ol ol ol, +ol ol ol ol ol ol ol ol ol ol ol ol { + list-style-type: lower-roman; +} + +ol { + list-style-position: outside; + margin-left: 0.25rem; +} + +/* Nested list spacing */ +ol ol, +ol ul, +ul ol { + margin-top: 0.1rem; + margin-bottom: 0.1rem; +} diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index db2771b2..8a41d79d 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -48,7 +48,9 @@ export const tiptapExtensions = [ Comment, TextAlign.configure({ types: ["heading", "paragraph"] }), TaskList, - TaskItem, + TaskItem.configure({ + nested: true, + }), Underline, LinkExtension, Superscript, From 90488a95b1c9f31c37526c426d6d1ab4aa0436f2 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 15 Jul 2025 06:27:48 +0100 Subject: [PATCH 15/24] feat: table background color, cell header and align (#1352) * feat: add toggle header cell button to table cell menu Added ability to toggle header cells directly from the table cell menu. This enhancement includes: - New toggle header cell button with IconTableRow icon - Consistent UI/UX with existing table menu patterns - Proper internationalization support * fix: typo in aria-label for toggle header cell button * feat: add table cell background color picker - Extended TableCell and TableHeader to support backgroundColor attribute - Created TableBackgroundColor component with 21 color options - Integrated color picker into table cell menu using Mantine UI - Added support for both regular cells and header cells - Updated imports to use custom TableHeader from @docmost/editor-ext * feat: add text alignment to table cell menu - Created TableTextAlignment component with left, center, and right alignment options - Integrated alignment selector into table cell menu - Shows current alignment icon in the button - Displays checkmark next to active alignment in dropdown * background colors * table background color in dark mode * add bg color name * rename color attribute * increase minimum table width --- .../table/table-background-color.tsx | 145 ++++++++++++++++++ .../components/table/table-cell-menu.tsx | 21 +++ .../components/table/table-text-alignment.tsx | 109 +++++++++++++ .../features/editor/extensions/extensions.ts | 2 +- .../src/features/editor/styles/table.css | 53 ++++++- .../src/collaboration/collaboration.util.ts | 12 +- packages/editor-ext/src/lib/table/cell.ts | 31 ++++ packages/editor-ext/src/lib/table/header.ts | 37 +++++ packages/editor-ext/src/lib/table/index.ts | 1 + 9 files changed, 401 insertions(+), 10 deletions(-) create mode 100644 apps/client/src/features/editor/components/table/table-background-color.tsx create mode 100644 apps/client/src/features/editor/components/table/table-text-alignment.tsx create mode 100644 packages/editor-ext/src/lib/table/header.ts diff --git a/apps/client/src/features/editor/components/table/table-background-color.tsx b/apps/client/src/features/editor/components/table/table-background-color.tsx new file mode 100644 index 00000000..204f0b02 --- /dev/null +++ b/apps/client/src/features/editor/components/table/table-background-color.tsx @@ -0,0 +1,145 @@ +import React, { FC } from "react"; +import { IconCheck, IconPalette } from "@tabler/icons-react"; +import { + ActionIcon, + ColorSwatch, + Popover, + Stack, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; +import { useEditor } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; + +export interface TableColorItem { + name: string; + color: string; +} + +interface TableBackgroundColorProps { + editor: ReturnType; +} + +const TABLE_COLORS: TableColorItem[] = [ + { name: "Default", color: "" }, + { name: "Blue", color: "#b4d5ff" }, + { name: "Green", color: "#acf5d2" }, + { name: "Yellow", color: "#fef1b4" }, + { name: "Red", color: "#ffbead" }, + { name: "Pink", color: "#ffc7fe" }, + { name: "Gray", color: "#eaecef" }, + { name: "Purple", color: "#c1b7f2" }, +]; + +export const TableBackgroundColor: FC = ({ + editor, +}) => { + const { t } = useTranslation(); + const [opened, setOpened] = React.useState(false); + + const setTableCellBackground = (color: string, colorName: string) => { + editor + .chain() + .focus() + .updateAttributes("tableCell", { + backgroundColor: color || null, + backgroundColorName: color ? colorName : null + }) + .updateAttributes("tableHeader", { + backgroundColor: color || null, + backgroundColorName: color ? colorName : null + }) + .run(); + setOpened(false); + }; + + // Get current cell's background color + const getCurrentColor = () => { + if (editor.isActive("tableCell")) { + const attrs = editor.getAttributes("tableCell"); + return attrs.backgroundColor || ""; + } + if (editor.isActive("tableHeader")) { + const attrs = editor.getAttributes("tableHeader"); + return attrs.backgroundColor || ""; + } + return ""; + }; + + const currentColor = getCurrentColor(); + + return ( + + + + setOpened(!opened)} + > + + + + + + + + + {t("Background color")} + + +
+ {TABLE_COLORS.map((item, index) => ( + setTableCellBackground(item.color, item.name)} + style={{ + position: "relative", + width: "24px", + height: "24px", + }} + title={t(item.name)} + > + + {currentColor === item.color && ( + + )} + + + ))} +
+
+
+
+ ); +}; diff --git a/apps/client/src/features/editor/components/table/table-cell-menu.tsx b/apps/client/src/features/editor/components/table/table-cell-menu.tsx index e348ea6e..2ea2e8dd 100644 --- a/apps/client/src/features/editor/components/table/table-cell-menu.tsx +++ b/apps/client/src/features/editor/components/table/table-cell-menu.tsx @@ -12,8 +12,11 @@ import { IconColumnRemove, IconRowRemove, IconSquareToggle, + IconTableRow, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { TableBackgroundColor } from "./table-background-color"; +import { TableTextAlignment } from "./table-text-alignment"; export const TableCellMenu = React.memo( ({ editor, appendTo }: EditorMenuProps): JSX.Element => { @@ -45,6 +48,10 @@ export const TableCellMenu = React.memo( editor.chain().focus().deleteRow().run(); }, [editor]); + const toggleHeaderCell = useCallback(() => { + editor.chain().focus().toggleHeaderCell().run(); + }, [editor]); + return ( + + + + + + + + + ); diff --git a/apps/client/src/features/editor/components/table/table-text-alignment.tsx b/apps/client/src/features/editor/components/table/table-text-alignment.tsx new file mode 100644 index 00000000..0cde7f44 --- /dev/null +++ b/apps/client/src/features/editor/components/table/table-text-alignment.tsx @@ -0,0 +1,109 @@ +import React, { FC } from "react"; +import { + IconAlignCenter, + IconAlignLeft, + IconAlignRight, + IconCheck, +} from "@tabler/icons-react"; +import { + ActionIcon, + Button, + Popover, + rem, + ScrollArea, + Tooltip, +} from "@mantine/core"; +import { useEditor } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; + +interface TableTextAlignmentProps { + editor: ReturnType; +} + +interface AlignmentItem { + name: string; + icon: React.ElementType; + command: () => void; + isActive: () => boolean; + value: string; +} + +export const TableTextAlignment: FC = ({ editor }) => { + const { t } = useTranslation(); + const [opened, setOpened] = React.useState(false); + + const items: AlignmentItem[] = [ + { + name: "Align left", + value: "left", + isActive: () => editor.isActive({ textAlign: "left" }), + command: () => editor.chain().focus().setTextAlign("left").run(), + icon: IconAlignLeft, + }, + { + name: "Align center", + value: "center", + isActive: () => editor.isActive({ textAlign: "center" }), + command: () => editor.chain().focus().setTextAlign("center").run(), + icon: IconAlignCenter, + }, + { + name: "Align right", + value: "right", + isActive: () => editor.isActive({ textAlign: "right" }), + command: () => editor.chain().focus().setTextAlign("right").run(), + icon: IconAlignRight, + }, + ]; + + const activeItem = items.find((item) => item.isActive()) || items[0]; + + return ( + + + + setOpened(!opened)} + > + + + + + + + + + {items.map((item, index) => ( + + ))} + + + + + ); +}; \ No newline at end of file diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 7b83fd31..4558151d 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -11,7 +11,6 @@ import { Typography } from "@tiptap/extension-typography"; import { TextStyle } from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; import Table from "@tiptap/extension-table"; -import TableHeader from "@tiptap/extension-table-header"; import SlashCommand from "@/features/editor/extensions/slash-command"; import { Collaboration } from "@tiptap/extension-collaboration"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; @@ -25,6 +24,7 @@ import { MathInline, TableCell, TableRow, + TableHeader, TrailingNode, TiptapImage, Callout, diff --git a/apps/client/src/features/editor/styles/table.css b/apps/client/src/features/editor/styles/table.css index 7d02ef03..d60a299c 100644 --- a/apps/client/src/features/editor/styles/table.css +++ b/apps/client/src/features/editor/styles/table.css @@ -4,6 +4,7 @@ overflow-x: auto; & table { overflow-x: hidden; + min-width: 700px !important; } } @@ -38,8 +39,8 @@ th { background-color: light-dark( - var(--mantine-color-gray-1), - var(--mantine-color-dark-5) + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) ); font-weight: bold; text-align: left; @@ -66,8 +67,54 @@ position: absolute; z-index: 2; } - } } +/* Table cell background colors with dark mode support */ +.ProseMirror { + table { + @mixin dark { + /* Blue */ + td[data-background-color="#b4d5ff"], + th[data-background-color="#b4d5ff"] { + background-color: #1a3a5c !important; + } + /* Green */ + td[data-background-color="#acf5d2"], + th[data-background-color="#acf5d2"] { + background-color: #1a4d3a !important; + } + + /* Yellow */ + td[data-background-color="#fef1b4"], + th[data-background-color="#fef1b4"] { + background-color: #7c5014 !important; + } + + /* Red */ + td[data-background-color="#ffbead"], + th[data-background-color="#ffbead"] { + background-color: #5c2a23 !important; + } + + /* Pink */ + td[data-background-color="#ffc7fe"], + th[data-background-color="#ffc7fe"] { + background-color: #4d2a4d !important; + } + + /* Gray */ + td[data-background-color="#eaecef"], + th[data-background-color="#eaecef"] { + background-color: #2a2e33 !important; + } + + /* Purple */ + td[data-background-color="#c1b7f2"], + th[data-background-color="#c1b7f2"] { + background-color: #3a2f5c !important; + } + } + } +} diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 8a41d79d..a766ec91 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -11,7 +11,6 @@ import { TextStyle } from '@tiptap/extension-text-style'; import { Color } from '@tiptap/extension-color'; import { Youtube } from '@tiptap/extension-youtube'; import Table from '@tiptap/extension-table'; -import TableHeader from '@tiptap/extension-table-header'; import { Callout, Comment, @@ -22,6 +21,7 @@ import { LinkExtension, MathBlock, MathInline, + TableHeader, TableCell, TableRow, TiptapImage, @@ -31,7 +31,7 @@ import { Drawio, Excalidraw, Embed, - Mention + Mention, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML } from '../common/helpers/prosemirror/html'; @@ -46,7 +46,7 @@ export const tiptapExtensions = [ codeBlock: false, }), Comment, - TextAlign.configure({ types: ["heading", "paragraph"] }), + TextAlign.configure({ types: ['heading', 'paragraph'] }), TaskList, TaskItem.configure({ nested: true, @@ -66,9 +66,9 @@ export const tiptapExtensions = [ DetailsContent, DetailsSummary, Table, - TableHeader, - TableRow, TableCell, + TableRow, + TableHeader, Youtube, TiptapImage, TiptapVideo, @@ -78,7 +78,7 @@ export const tiptapExtensions = [ Drawio, Excalidraw, Embed, - Mention + Mention, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/packages/editor-ext/src/lib/table/cell.ts b/packages/editor-ext/src/lib/table/cell.ts index 17ab1e29..0714d69a 100644 --- a/packages/editor-ext/src/lib/table/cell.ts +++ b/packages/editor-ext/src/lib/table/cell.ts @@ -3,4 +3,35 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell"; export const TableCell = TiptapTableCell.extend({ name: "tableCell", content: "paragraph+", + + addAttributes() { + return { + ...this.parent?.(), + backgroundColor: { + default: null, + parseHTML: (element) => element.style.backgroundColor || null, + renderHTML: (attributes) => { + if (!attributes.backgroundColor) { + return {}; + } + return { + style: `background-color: ${attributes.backgroundColor}`, + 'data-background-color': attributes.backgroundColor, + }; + }, + }, + backgroundColorName: { + default: null, + parseHTML: (element) => element.getAttribute('data-background-color-name') || null, + renderHTML: (attributes) => { + if (!attributes.backgroundColorName) { + return {}; + } + return { + 'data-background-color-name': attributes.backgroundColorName.toLowerCase(), + }; + }, + }, + }; + }, }); diff --git a/packages/editor-ext/src/lib/table/header.ts b/packages/editor-ext/src/lib/table/header.ts new file mode 100644 index 00000000..46b1efaf --- /dev/null +++ b/packages/editor-ext/src/lib/table/header.ts @@ -0,0 +1,37 @@ +import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header"; + +export const TableHeader = TiptapTableHeader.extend({ + name: "tableHeader", + content: "paragraph+", + + addAttributes() { + return { + ...this.parent?.(), + backgroundColor: { + default: null, + parseHTML: (element) => element.style.backgroundColor || null, + renderHTML: (attributes) => { + if (!attributes.backgroundColor) { + return {}; + } + return { + style: `background-color: ${attributes.backgroundColor}`, + 'data-background-color': attributes.backgroundColor, + }; + }, + }, + backgroundColorName: { + default: null, + parseHTML: (element) => element.getAttribute('data-background-color-name') || null, + renderHTML: (attributes) => { + if (!attributes.backgroundColorName) { + return {}; + } + return { + 'data-background-color-name': attributes.backgroundColorName.toLowerCase(), + }; + }, + }, + }; + }, +}); \ No newline at end of file diff --git a/packages/editor-ext/src/lib/table/index.ts b/packages/editor-ext/src/lib/table/index.ts index 5661ef84..656c1825 100644 --- a/packages/editor-ext/src/lib/table/index.ts +++ b/packages/editor-ext/src/lib/table/index.ts @@ -1,2 +1,3 @@ export * from "./row"; export * from "./cell"; +export * from "./header"; From 44e592763dc4c5ff2c2c35b0d288cdb759740802 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Tue, 15 Jul 2025 06:28:27 +0100 Subject: [PATCH 16/24] feat: quick theme toggle and Mantine 8 upgrade (#1369) * upgrade to mantine v8 * feat: quick theme toggle --- apps/client/package.json | 12 +- .../components/layouts/global/top-menu.tsx | 55 ++++++++- pnpm-lock.yaml | 106 +++++++++--------- 3 files changed, 112 insertions(+), 61 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index 9abc7c64..e8001c31 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,12 +16,12 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@excalidraw/excalidraw": "0.18.0-864353b", - "@mantine/core": "^7.17.0", - "@mantine/form": "^7.17.0", - "@mantine/hooks": "^7.17.0", - "@mantine/modals": "^7.17.0", - "@mantine/notifications": "^7.17.0", - "@mantine/spotlight": "^7.17.0", + "@mantine/core": "^8.1.3", + "@mantine/form": "^8.1.3", + "@mantine/hooks": "^8.1.3", + "@mantine/modals": "^8.1.3", + "@mantine/notifications": "^8.1.3", + "@mantine/spotlight": "^8.1.3", "@tabler/icons-react": "^3.34.0", "@tanstack/react-query": "^5.80.6", "@tiptap/extension-character-count": "^2.10.3", diff --git a/apps/client/src/components/layouts/global/top-menu.tsx b/apps/client/src/components/layouts/global/top-menu.tsx index 52cabb5a..d3a89ecc 100644 --- a/apps/client/src/components/layouts/global/top-menu.tsx +++ b/apps/client/src/components/layouts/global/top-menu.tsx @@ -1,9 +1,21 @@ -import { Group, Menu, UnstyledButton, Text } from "@mantine/core"; import { + Group, + Menu, + UnstyledButton, + Text, + useMantineColorScheme, +} from "@mantine/core"; +import { + IconBrightnessFilled, IconBrush, + IconCheck, IconChevronDown, + IconChevronRight, + IconDeviceDesktop, IconLogout, + IconMoon, IconSettings, + IconSun, IconUserCircle, IconUsers, } from "@tabler/icons-react"; @@ -19,6 +31,7 @@ export default function TopMenu() { const { t } = useTranslation(); const [currentUser] = useAtom(currentUserAtom); const { logout } = useAuth(); + const { colorScheme, setColorScheme } = useMantineColorScheme(); const user = currentUser?.user; const workspace = currentUser?.workspace; @@ -75,7 +88,7 @@ export default function TopMenu() { name={user.name} /> -
+
{user.name} @@ -101,6 +114,44 @@ export default function TopMenu() { {t("My preferences")} + + + }> + {t("Theme")} + + + + + setColorScheme("light")} + leftSection={} + rightSection={ + colorScheme === "light" ? : null + } + > + {t("Light")} + + setColorScheme("dark")} + leftSection={} + rightSection={ + colorScheme === "dark" ? : null + } + > + {t("Dark")} + + setColorScheme("auto")} + leftSection={} + rightSection={ + colorScheme === "auto" ? : null + } + > + {t("System settings")} + + + + }> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8c0c88c..5c38d553 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,23 +222,23 @@ importers: specifier: 0.18.0-864353b version: 0.18.0-864353b(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/core': - specifier: ^7.17.0 - version: 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^8.1.3 + version: 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/form': - specifier: ^7.17.0 - version: 7.17.0(react@18.3.1) + specifier: ^8.1.3 + version: 8.1.3(react@18.3.1) '@mantine/hooks': - specifier: ^7.17.0 - version: 7.17.0(react@18.3.1) + specifier: ^8.1.3 + version: 8.1.3(react@18.3.1) '@mantine/modals': - specifier: ^7.17.0 - version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^8.1.3 + version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/notifications': - specifier: ^7.17.0 - version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^8.1.3 + version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mantine/spotlight': - specifier: ^7.17.0 - version: 7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^8.1.3 + version: 8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tabler/icons-react': specifier: ^3.34.0 version: 3.34.0(react@18.3.1) @@ -2503,49 +2503,49 @@ packages: resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} - '@mantine/core@7.17.0': - resolution: {integrity: sha512-AU5UFewUNzBCUXIq5Jk6q402TEri7atZW61qHW6P0GufJ2W/JxGHRvgmHOVHTVIcuWQRCt9SBSqZoZ/vHs9LhA==} + '@mantine/core@8.1.3': + resolution: {integrity: sha512-2WOPC8GSN3MApet0MccSn6LaXRhcP6SVtZnbuHoqJ/atrfK7kLE66ILr4OXov7JAj1ASJ4Xk0bOXmu5fBExAvQ==} peerDependencies: - '@mantine/hooks': 7.17.0 + '@mantine/hooks': 8.1.3 react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@mantine/form@7.17.0': - resolution: {integrity: sha512-LONdeb+wL8h9fvyQ339ZFLxqrvYff+b+H+kginZhnr45OBTZDLXNVAt/YoKVFEkynF9WDJjdBVrXKcOZvPgmrA==} + '@mantine/form@8.1.3': + resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==} peerDependencies: react: ^18.x || ^19.x - '@mantine/hooks@7.17.0': - resolution: {integrity: sha512-vo3K49mLy1nJ8LQNb5KDbJgnX0xwt3Y8JOF3ythjB5LEFMptdLSSgulu64zj+QHtzvffFCsMb05DbTLLpVP/JQ==} + '@mantine/hooks@8.1.3': + resolution: {integrity: sha512-yL4SbyYjrkmtIhscswajNz9RL0iO2+V8CMtOi0KISch2rPNvTAJNumFuZaXgj4UHeDc0JQYSmcZ+EW8NGm7xcQ==} peerDependencies: react: ^18.x || ^19.x - '@mantine/modals@7.17.0': - resolution: {integrity: sha512-4sfiFxIxMxfm2RH4jXMN+cr8tFS5AexXG4TY7TRN/ySdkiWtFVvDe5l2/KRWWeWwDUb7wQhht8Ompj5KtexlEA==} + '@mantine/modals@8.1.3': + resolution: {integrity: sha512-PTLquO7OuYHrbezhjqf1fNwxU1NKZJmNYDOll6RHp6FPQ80xCVWQqVFsj3R8XsLluu2b5ygTYi+avWrUr1GvGg==} peerDependencies: - '@mantine/core': 7.17.0 - '@mantine/hooks': 7.17.0 + '@mantine/core': 8.1.3 + '@mantine/hooks': 8.1.3 react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@mantine/notifications@7.17.0': - resolution: {integrity: sha512-xejr1WW02NrrrE4HPDoownILJubcjLLwCDeTk907ZeeHKBEPut7RukEq6gLzOZBhNhKdPM+vCM7GcbXdaLZq/Q==} + '@mantine/notifications@8.1.3': + resolution: {integrity: sha512-Xy6f/l1yLTo77hz8X80sOuY+HW80e1rn8ucygx9TAexK5+XtyriOv26TQ3EJ6Ej5jlchtZRFEUJ4tJGRWjGCNg==} peerDependencies: - '@mantine/core': 7.17.0 - '@mantine/hooks': 7.17.0 + '@mantine/core': 8.1.3 + '@mantine/hooks': 8.1.3 react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@mantine/spotlight@7.17.0': - resolution: {integrity: sha512-T7xfXxyDg2fxf7qvKwBozQ8HBnTQ2GRCIIoeYdAoiHoFQUS7NbBAnqrjdr5iYZpJqyLRXn8uFI7DX1Zdzd6/PQ==} + '@mantine/spotlight@8.1.3': + resolution: {integrity: sha512-GhJbSoUdcALGSMLC/zjVVncRDyvxwxjtlzFeHLuY0Dgkgj+60x3tnzAulDrqYVhLMk7fGyex22VV/Xwl7mG1+Q==} peerDependencies: - '@mantine/core': 7.17.0 - '@mantine/hooks': 7.17.0 + '@mantine/core': 8.1.3 + '@mantine/hooks': 8.1.3 react: ^18.x || ^19.x react-dom: ^18.x || ^19.x - '@mantine/store@7.17.0': - resolution: {integrity: sha512-nhWRYRLqvAjrD/ApKCXxuHyTWg2b5dC06Z5gmO8udj4pBgndNf9nmCl+Of90H6bgOa56moJA7UQyXoF1SfxqVg==} + '@mantine/store@8.1.3': + resolution: {integrity: sha512-rO72LfSJqSNCwufqJxTWiHMyOR6sR3mqAcnBcw/f5aTvyOYoHZzlm4q4+TL8/2vYGRVsr9YM2Ez6HQ1vk/RR8g==} peerDependencies: react: ^18.x || ^19.x @@ -8265,8 +8265,8 @@ packages: '@types/react': optional: true - react-textarea-autosize@8.5.6: - resolution: {integrity: sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==} + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} engines: {node: '>=10'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -12228,55 +12228,55 @@ snapshots: '@lukeed/ms@2.0.2': {} - '@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 7.17.0(react@18.3.1) + '@mantine/hooks': 8.1.3(react@18.3.1) clsx: 2.1.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-number-format: 5.4.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-remove-scroll: 2.6.3(@types/react@18.3.12)(react@18.3.1) - react-textarea-autosize: 8.5.6(@types/react@18.3.12)(react@18.3.1) + react-textarea-autosize: 8.5.9(@types/react@18.3.12)(react@18.3.1) type-fest: 4.28.1 transitivePeerDependencies: - '@types/react' - '@mantine/form@7.17.0(react@18.3.1)': + '@mantine/form@8.1.3(react@18.3.1)': dependencies: fast-deep-equal: 3.1.3 klona: 2.0.6 react: 18.3.1 - '@mantine/hooks@7.17.0(react@18.3.1)': + '@mantine/hooks@8.1.3(react@18.3.1)': dependencies: react: 18.3.1 - '@mantine/modals@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/modals@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 7.17.0(react@18.3.1) + '@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/hooks': 8.1.3(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@mantine/notifications@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/notifications@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 7.17.0(react@18.3.1) - '@mantine/store': 7.17.0(react@18.3.1) + '@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/hooks': 8.1.3(react@18.3.1) + '@mantine/store': 8.1.3(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/spotlight@7.17.0(@mantine/core@7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.17.0(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mantine/spotlight@8.1.3(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@mantine/core': 7.17.0(@mantine/hooks@7.17.0(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 7.17.0(react@18.3.1) - '@mantine/store': 7.17.0(react@18.3.1) + '@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mantine/hooks': 8.1.3(react@18.3.1) + '@mantine/store': 8.1.3(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@mantine/store@7.17.0(react@18.3.1)': + '@mantine/store@8.1.3(react@18.3.1)': dependencies: react: 18.3.1 @@ -18849,7 +18849,7 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 - react-textarea-autosize@8.5.6(@types/react@18.3.12)(react@18.3.1): + react-textarea-autosize@8.5.9(@types/react@18.3.12)(react@18.3.1): dependencies: '@babel/runtime': 7.25.6 react: 18.3.1 From 4dfed2b2afc283893e44e310fecf00b1ac7c6ff1 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:00:06 +0100 Subject: [PATCH 17/24] queue import attachments upload (#1353) --- apps/server/package.json | 1 + .../services/import-attachment.service.ts | 156 ++++++++++++++---- pnpm-lock.yaml | 17 ++ 3 files changed, 145 insertions(+), 29 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 63fa0be1..4106ba89 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -71,6 +71,7 @@ "nestjs-kysely": "^1.2.0", "nodemailer": "^7.0.3", "openid-client": "^5.7.1", + "p-limit": "^6.2.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pg": "^8.16.0", diff --git a/apps/server/src/integrations/import/services/import-attachment.service.ts b/apps/server/src/integrations/import/services/import-attachment.service.ts index cd9039e2..b9a488a9 100644 --- a/apps/server/src/integrations/import/services/import-attachment.service.ts +++ b/apps/server/src/integrations/import/services/import-attachment.service.ts @@ -14,10 +14,14 @@ import { AttachmentType } from '../../../core/attachment/attachment.constants'; import { unwrapFromParagraph } from '../utils/import-formatter'; import { resolveRelativeAttachmentPath } from '../utils/import.utils'; import { load } from 'cheerio'; +import pLimit from 'p-limit'; @Injectable() export class ImportAttachmentService { private readonly logger = new Logger(ImportAttachmentService.name); + private readonly CONCURRENT_UPLOADS = 3; + private readonly MAX_RETRIES = 2; + private readonly RETRY_DELAY = 2000; constructor( private readonly storageService: StorageService, @@ -41,7 +45,14 @@ export class ImportAttachmentService { attachmentCandidates, } = opts; - const attachmentTasks: Promise[] = []; + const attachmentTasks: (() => Promise)[] = []; + const limit = pLimit(this.CONCURRENT_UPLOADS); + const uploadStats = { + total: 0, + completed: 0, + failed: 0, + failedFiles: [] as string[], + }; /** * Cache keyed by the *relative* path that appears in the HTML. @@ -74,30 +85,16 @@ export class ImportAttachmentService { const apiFilePath = `/api/files/${attachmentId}/${fileNameWithExt}`; - attachmentTasks.push( - (async () => { - const fileStream = createReadStream(abs); - await this.storageService.uploadStream(storageFilePath, fileStream); - const stat = await fs.stat(abs); - - await this.db - .insertInto('attachments') - .values({ - id: attachmentId, - filePath: storageFilePath, - fileName: fileNameWithExt, - fileSize: stat.size, - mimeType: getMimeType(fileNameWithExt), - type: 'file', - fileExt: ext, - creatorId: fileTask.creatorId, - workspaceId: fileTask.workspaceId, - pageId, - spaceId: fileTask.spaceId, - }) - .execute(); - })(), - ); + attachmentTasks.push(() => this.uploadWithRetry({ + abs, + storageFilePath, + attachmentId, + fileNameWithExt, + ext, + pageId, + fileTask, + uploadStats, + })); return { attachmentId, @@ -292,12 +289,113 @@ export class ImportAttachmentService { } // wait for all uploads & DB inserts - try { - await Promise.all(attachmentTasks); - } catch (err) { - this.logger.log('Import attachment upload error', err); + uploadStats.total = attachmentTasks.length; + + if (uploadStats.total > 0) { + this.logger.debug(`Starting upload of ${uploadStats.total} attachments...`); + + try { + await Promise.all( + attachmentTasks.map(task => limit(task)) + ); + } catch (err) { + this.logger.error('Import attachment upload error', err); + } + + this.logger.debug( + `Upload completed: ${uploadStats.completed}/${uploadStats.total} successful, ${uploadStats.failed} failed` + ); + + if (uploadStats.failed > 0) { + this.logger.warn( + `Failed to upload ${uploadStats.failed} files:`, + uploadStats.failedFiles + ); + } } return $.root().html() || ''; } + + private async uploadWithRetry(opts: { + abs: string; + storageFilePath: string; + attachmentId: string; + fileNameWithExt: string; + ext: string; + pageId: string; + fileTask: FileTask; + uploadStats: { + total: number; + completed: number; + failed: number; + failedFiles: string[]; + }; + }): Promise { + const { + abs, + storageFilePath, + attachmentId, + fileNameWithExt, + ext, + pageId, + fileTask, + uploadStats, + } = opts; + + let lastError: Error; + + for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { + try { + const fileStream = createReadStream(abs); + await this.storageService.uploadStream(storageFilePath, fileStream); + const stat = await fs.stat(abs); + + await this.db + .insertInto('attachments') + .values({ + id: attachmentId, + filePath: storageFilePath, + fileName: fileNameWithExt, + fileSize: stat.size, + mimeType: getMimeType(fileNameWithExt), + type: 'file', + fileExt: ext, + creatorId: fileTask.creatorId, + workspaceId: fileTask.workspaceId, + pageId, + spaceId: fileTask.spaceId, + }) + .execute(); + + uploadStats.completed++; + + if (uploadStats.completed % 10 === 0) { + this.logger.debug( + `Upload progress: ${uploadStats.completed}/${uploadStats.total}` + ); + } + + return; + } catch (error) { + lastError = error as Error; + this.logger.warn( + `Upload attempt ${attempt}/${this.MAX_RETRIES} failed for ${fileNameWithExt}: ${error instanceof Error ? error.message : String(error)}` + ); + + if (attempt < this.MAX_RETRIES) { + await new Promise(resolve => + setTimeout(resolve, this.RETRY_DELAY * attempt) + ); + } + } + } + + uploadStats.failed++; + uploadStats.failedFiles.push(fileNameWithExt); + this.logger.error( + `Failed to upload ${fileNameWithExt} after ${this.MAX_RETRIES} attempts:`, + lastError + ); + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c38d553..4c7a990b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -534,6 +534,9 @@ importers: openid-client: specifier: ^5.7.1 version: 5.7.1 + p-limit: + specifier: ^6.2.0 + version: 6.2.0 passport-google-oauth20: specifier: ^2.0.0 version: 2.0.0 @@ -7637,6 +7640,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -9567,6 +9574,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.1: + resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} + engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.2: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} @@ -18193,6 +18204,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.1 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -20183,6 +20198,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.1: {} + yoctocolors-cjs@2.1.2: {} zeed-dom@0.15.1: From f8dc9845a7c30605916b81301fb126a504342221 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 21 Jul 2025 05:02:40 +0100 Subject: [PATCH 18/24] fix page tree api atom (#1391) - The tree api atom state is not always set, which makes it impossble to create new pages since the buttons rely on it. - this should fix it. --- .../page/tree/components/space-tree.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index db818518..26f07a7b 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -90,8 +90,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const treeApiRef = useRef>(); const [openTreeNodes, setOpenTreeNodes] = useAtom(openTreeNodesAtom); const rootElement = useRef(); + const [isRootReady, setIsRootReady] = useState(false); const { ref: sizeRef, width, height } = useElementSize(); - const mergedRef = useMergedRef(rootElement, sizeRef); + const mergedRef = useMergedRef((element) => { + rootElement.current = element; + if (element && !isRootReady) { + setIsRootReady(true); + } + }, sizeRef); const [isDataLoaded, setIsDataLoaded] = useState(false); const { data: currentPage } = usePageQuery({ pageId: extractPageSlugId(pageSlug), @@ -199,16 +205,17 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { } }, [currentPage?.id]); + // Clean up tree API on unmount useEffect(() => { - if (treeApiRef.current) { + return () => { // @ts-ignore - setTreeApi(treeApiRef.current); - } - }, [treeApiRef.current]); + setTreeApi(null); + }; + }, [setTreeApi]); return (
- {rootElement.current && ( + {isRootReady && rootElement.current && ( node?.spaceId === spaceId)} disableDrag={readOnly} @@ -217,7 +224,13 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { {...controllers} width={width} height={rootElement.current.clientHeight} - ref={treeApiRef} + ref={(ref) => { + treeApiRef.current = ref; + if (ref) { + //@ts-ignore + setTreeApi(ref); + } + }} openByDefault={false} disableMultiSelection={true} className={classes.tree} From 85228446737829ca6df0dc3efd904b51e80255fa Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:39:57 +0100 Subject: [PATCH 19/24] feat: duplicate page in same space (#1394) * fix internal links in copies pages * feat: duplicate page in same space * fix children --- .../public/locales/en-US/translation.json | 3 + .../page/components/copy-page-modal.tsx | 4 +- .../features/page/services/page-service.ts | 4 +- .../page/tree/components/space-tree.tsx | 91 ++++++++++++++++++- .../src/features/page/types/page.types.ts | 2 +- ...copy-page.dto.ts => duplicate-page.dto.ts} | 8 +- apps/server/src/core/page/page.controller.ts | 54 ++++++----- .../src/core/page/services/page.service.ts | 91 +++++++++++++++++-- 8 files changed, 214 insertions(+), 43 deletions(-) rename apps/server/src/core/page/dto/{copy-page.dto.ts => duplicate-page.dto.ts} (68%) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 769ff61a..6c2289d8 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -222,7 +222,9 @@ "Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.", "Invite link": "Invite link", "Copy": "Copy", + "Copy to space": "Copy to space", "Copied": "Copied", + "Duplicate": "Duplicate", "Select a user": "Select a user", "Select a group": "Select a group", "Export all pages and attachments in this space.": "Export all pages and attachments in this space.", @@ -390,6 +392,7 @@ "Copy page": "Copy page", "Copy page to a different space.": "Copy page to a different space.", "Page copied successfully": "Page copied successfully", + "Page duplicated successfully": "Page duplicated successfully", "Find": "Find", "Not found": "Not found", "Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)", diff --git a/apps/client/src/features/page/components/copy-page-modal.tsx b/apps/client/src/features/page/components/copy-page-modal.tsx index e639fbac..4745f731 100644 --- a/apps/client/src/features/page/components/copy-page-modal.tsx +++ b/apps/client/src/features/page/components/copy-page-modal.tsx @@ -1,5 +1,5 @@ import { Modal, Button, Group, Text } from "@mantine/core"; -import { copyPageToSpace } from "@/features/page/services/page-service.ts"; +import { duplicatePage } from "@/features/page/services/page-service.ts"; import { useState } from "react"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; @@ -30,7 +30,7 @@ export default function CopyPageModal({ if (!targetSpace) return; try { - const copiedPage = await copyPageToSpace({ + const copiedPage = await duplicatePage({ pageId, spaceId: targetSpace.id, }); diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index a8e3d256..ad2be4f7 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -42,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise { await api.post("/pages/move-to-space", data); } -export async function copyPageToSpace(data: ICopyPageToSpace): Promise { - const req = await api.post("/pages/copy-to-space", data); +export async function duplicatePage(data: ICopyPageToSpace): Promise { + const req = await api.post("/pages/duplicate", data); return req.data; } diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 26f07a7b..dad5f1e4 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -1,4 +1,10 @@ -import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; +import { + NodeApi, + NodeRendererProps, + Tree, + TreeApi, + SimpleTree, +} from "react-arborist"; import { atom, useAtom } from "jotai"; import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { @@ -66,6 +72,7 @@ import MovePageModal from "../../components/move-page-modal.tsx"; import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import CopyPageModal from "../../components/copy-page-modal.tsx"; +import { duplicatePage } from "../../services/page-service.ts"; interface SpaceTreeProps { spaceId: string; @@ -396,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps) { {node.data.name || t("untitled")}
- + {!tree.props.disableEdit && ( ; treeApi: TreeApi; + spaceId: string; } -function NodeMenu({ node, treeApi }: NodeMenuProps) { +function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) { const { t } = useTranslation(); const clipboard = useClipboard({ timeout: 500 }); const { spaceSlug } = useParams(); const { openDeleteModal } = useDeletePageModal(); + const [data, setData] = useAtom(treeDataAtom); + const emit = useQueryEmit(); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); const [ @@ -474,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { notifications.show({ message: t("Link copied") }); }; + const handleDuplicatePage = async () => { + try { + const duplicatedPage = await duplicatePage({ + pageId: node.id, + }); + + // Find the index of the current node + const parentId = + node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__" + ? null + : node.parent?.id; + const siblings = parentId ? node.parent.children : treeApi?.props.data; + const currentIndex = + siblings?.findIndex((sibling) => sibling.id === node.id) || 0; + const newIndex = currentIndex + 1; + + // Add the duplicated page to the tree + const treeNodeData: SpaceTreeNode = { + id: duplicatedPage.id, + slugId: duplicatedPage.slugId, + name: duplicatedPage.title, + position: duplicatedPage.position, + spaceId: duplicatedPage.spaceId, + parentPageId: duplicatedPage.parentPageId, + icon: duplicatedPage.icon, + hasChildren: duplicatedPage.hasChildren, + children: [], + }; + + // Update local tree + const simpleTree = new SimpleTree(data); + simpleTree.create({ + parentId, + index: newIndex, + data: treeNodeData, + }); + setData(simpleTree.data); + + // Emit socket event + setTimeout(() => { + emit({ + operation: "addTreeNode", + spaceId: spaceId, + payload: { + parentId, + index: newIndex, + data: treeNodeData, + }, + }); + }, 50); + + notifications.show({ + message: t("Page duplicated successfully"), + }); + } catch (err) { + notifications.show({ + message: err.response?.data.message || "An error occurred", + color: "red", + }); + } + }; + return ( <> @@ -518,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { {!(treeApi.props.disableEdit as boolean) && ( <> + } + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + handleDuplicatePage(); + }} + > + {t("Duplicate")} + + } onClick={(e) => { @@ -537,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) { openCopyPageModal(); }} > - {t("Copy")} + {t("Copy to space")} diff --git a/apps/client/src/features/page/types/page.types.ts b/apps/client/src/features/page/types/page.types.ts index 19dc18fd..f97c4514 100644 --- a/apps/client/src/features/page/types/page.types.ts +++ b/apps/client/src/features/page/types/page.types.ts @@ -49,7 +49,7 @@ export interface IMovePageToSpace { export interface ICopyPageToSpace { pageId: string; - spaceId: string; + spaceId?: string; } export interface SidebarPagesParams { diff --git a/apps/server/src/core/page/dto/copy-page.dto.ts b/apps/server/src/core/page/dto/duplicate-page.dto.ts similarity index 68% rename from apps/server/src/core/page/dto/copy-page.dto.ts rename to apps/server/src/core/page/dto/duplicate-page.dto.ts index 09de3083..395ad9a3 100644 --- a/apps/server/src/core/page/dto/copy-page.dto.ts +++ b/apps/server/src/core/page/dto/duplicate-page.dto.ts @@ -1,13 +1,13 @@ -import { IsString, IsNotEmpty } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; -export class CopyPageToSpaceDto { +export class DuplicatePageDto { @IsNotEmpty() @IsString() pageId: string; - @IsNotEmpty() + @IsOptional() @IsString() - spaceId: string; + spaceId?: string; } export type CopyPageMapEntry = { diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 145c5313..565ecd1e 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -28,7 +28,7 @@ import { import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { RecentPageDto } from './dto/recent-page.dto'; -import { CopyPageToSpaceDto } from './dto/copy-page.dto'; +import { DuplicatePageDto } from './dto/duplicate-page.dto'; @UseGuards(JwtAuthGuard) @Controller('pages') @@ -242,33 +242,41 @@ export class PageController { } @HttpCode(HttpStatus.OK) - @Post('copy-to-space') - async copyPageToSpace( - @Body() dto: CopyPageToSpaceDto, - @AuthUser() user: User, - ) { + @Post('duplicate') + async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) { const copiedPage = await this.pageRepo.findById(dto.pageId); if (!copiedPage) { throw new NotFoundException('Page to copy not found'); } - if (copiedPage.spaceId === dto.spaceId) { - throw new BadRequestException('Page is already in this space'); + + // If spaceId is provided, it's a copy to different space + if (dto.spaceId) { + const abilities = await Promise.all([ + this.spaceAbility.createForUser(user, copiedPage.spaceId), + this.spaceAbility.createForUser(user, dto.spaceId), + ]); + + if ( + abilities.some((ability) => + ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page), + ) + ) { + throw new ForbiddenException(); + } + + return this.pageService.duplicatePage(copiedPage, dto.spaceId, user); + } else { + // If no spaceId, it's a duplicate in same space + const ability = await this.spaceAbility.createForUser( + user, + copiedPage.spaceId, + ); + if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { + throw new ForbiddenException(); + } + + return this.pageService.duplicatePage(copiedPage, undefined, user); } - - const abilities = await Promise.all([ - this.spaceAbility.createForUser(user, copiedPage.spaceId), - this.spaceAbility.createForUser(user, dto.spaceId), - ]); - - if ( - abilities.some((ability) => - ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page), - ) - ) { - throw new ForbiddenException(); - } - - return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user); } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/page/services/page.service.ts b/apps/server/src/core/page/services/page.service.ts index 41f3055e..4f96e0ca 100644 --- a/apps/server/src/core/page/services/page.service.ts +++ b/apps/server/src/core/page/services/page.service.ts @@ -31,7 +31,10 @@ import { removeMarkTypeFromDoc, } from '../../../common/helpers/prosemirror/utils'; import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; -import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto'; +import { + CopyPageMapEntry, + ICopyPageAttachment, +} from '../dto/duplicate-page.dto'; import { Node as PMNode } from '@tiptap/pm/model'; import { StorageService } from '../../../integrations/storage/storage.service'; @@ -258,11 +261,52 @@ export class PageService { }); } - async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) { - //TODO: - // i. maintain internal links within copied pages + async duplicatePage( + rootPage: Page, + targetSpaceId: string | undefined, + authUser: User, + ) { + const spaceId = targetSpaceId || rootPage.spaceId; + const isDuplicateInSameSpace = + !targetSpaceId || targetSpaceId === rootPage.spaceId; - const nextPosition = await this.nextPagePosition(spaceId); + let nextPosition: string; + + if (isDuplicateInSameSpace) { + // For duplicate in same space, position right after the original page + let siblingQuery = this.db + .selectFrom('pages') + .select(['position']) + .where('spaceId', '=', rootPage.spaceId) + .where('position', '>', rootPage.position); + + if (rootPage.parentPageId) { + siblingQuery = siblingQuery.where( + 'parentPageId', + '=', + rootPage.parentPageId, + ); + } else { + siblingQuery = siblingQuery.where('parentPageId', 'is', null); + } + + const nextSibling = await siblingQuery + .orderBy('position', 'asc') + .limit(1) + .executeTakeFirst(); + + if (nextSibling) { + nextPosition = generateJitteredKeyBetween( + rootPage.position, + nextSibling.position, + ); + } else { + nextPosition = generateJitteredKeyBetween(rootPage.position, null); + } + } else { + // For copy to different space, position at the end + nextPosition = await this.nextPagePosition(spaceId); + } const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, { includeContent: true, @@ -326,12 +370,38 @@ export class PageService { }); } + // Update internal page links in mention nodes + prosemirrorDoc.descendants((node: PMNode) => { + if ( + node.type.name === 'mention' && + node.attrs.entityType === 'page' + ) { + const referencedPageId = node.attrs.entityId; + + // Check if the referenced page is within the pages being copied + if (referencedPageId && pageMap.has(referencedPageId)) { + const mappedPage = pageMap.get(referencedPageId); + //@ts-ignore + node.attrs.entityId = mappedPage.newPageId; + //@ts-ignore + node.attrs.slugId = mappedPage.newSlugId; + } + } + }); + const prosemirrorJson = prosemirrorDoc.toJSON(); + // Add "Copy of " prefix to the root page title only for duplicates in same space + let title = page.title; + if (isDuplicateInSameSpace && page.id === rootPage.id) { + const originalTitle = page.title || 'Untitled'; + title = `Copy of ${originalTitle}`; + } + return { id: pageFromMap.newPageId, slugId: pageFromMap.newSlugId, - title: page.title, + title: title, icon: page.icon, content: prosemirrorJson, textContent: jsonToText(prosemirrorJson), @@ -401,9 +471,16 @@ export class PageService { } const newPageId = pageMap.get(rootPage.id).newPageId; - return await this.pageRepo.findById(newPageId, { + const duplicatedPage = await this.pageRepo.findById(newPageId, { includeSpace: true, }); + + const hasChildren = pages.length > 1; + + return { + ...duplicatedPage, + hasChildren, + }; } async movePage(dto: MovePageDto, movedPage: Page) { From 662460252fac1b24cf63f46d8b29d5f7cdf0cf1f Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:18:53 +0100 Subject: [PATCH 20/24] feat(EE): MFA implementation (#1381) * feat(EE): MFA implementation for enterprise edition - Add TOTP-based two-factor authentication - Add backup codes support - Add MFA enforcement at workspace level - Add MFA setup and challenge UI pages - Support MFA for login and password reset flows - Add MFA validation for secure pages * fix types * remove unused object * sync * remove unused type * sync * refactor: rename MFA enabled field to is_enabled * sync --- apps/client/package.json | 1 + .../public/locales/en-US/translation.json | 70 +++- apps/client/src/App.tsx | 14 +- .../mfa/components/mfa-backup-code-input.tsx | 81 ++++ .../mfa/components/mfa-backup-codes-modal.tsx | 193 ++++++++++ .../mfa/components/mfa-challenge.module.css | 12 + .../src/ee/mfa/components/mfa-challenge.tsx | 160 ++++++++ .../ee/mfa/components/mfa-disable-modal.tsx | 124 +++++++ .../src/ee/mfa/components/mfa-settings.tsx | 112 ++++++ .../src/ee/mfa/components/mfa-setup-modal.tsx | 347 ++++++++++++++++++ .../ee/mfa/components/mfa-setup-required.tsx | 48 +++ .../src/ee/mfa/components/mfa.module.css | 31 ++ .../ee/mfa/hooks/use-mfa-page-protection.ts | 51 +++ apps/client/src/ee/mfa/index.ts | 19 + .../src/ee/mfa/pages/mfa-challenge-page.tsx | 13 + .../ee/mfa/pages/mfa-setup-required-page.tsx | 113 ++++++ .../client/src/ee/mfa/services/mfa-service.ts | 61 +++ apps/client/src/ee/mfa/types/mfa.types.ts | 62 ++++ .../ee/security/components/enforce-mfa.tsx | 66 ++++ .../client/src/ee/security/pages/security.tsx | 5 + .../auth/components/invite-sign-up-form.tsx | 3 +- .../src/features/auth/hooks/use-auth.ts | 48 ++- .../features/auth/services/auth-service.ts | 13 +- .../src/features/auth/types/auth.types.ts | 7 + .../user/components/account-mfa-section.tsx | 15 + .../features/user/components/change-email.tsx | 4 +- .../user/components/change-password.tsx | 4 +- .../workspace/services/workspace-service.ts | 5 +- .../workspace/types/workspace.types.ts | 1 + apps/client/src/lib/app-route.ts | 2 + .../settings/account/account-settings.tsx | 17 +- apps/server/package.json | 1 + apps/server/src/common/helpers/utils.ts | 8 + apps/server/src/core/auth/auth.controller.ts | 61 ++- apps/server/src/core/auth/dto/jwt-payload.ts | 6 + .../src/core/auth/services/auth.service.ts | 28 +- .../src/core/auth/services/token.service.ts | 17 + .../src/core/auth/strategies/jwt.strategy.ts | 8 +- .../controllers/workspace.controller.ts | 17 +- .../workspace/dto/update-workspace.dto.ts | 4 + .../services/workspace-invitation.service.ts | 18 +- .../migrations/20250715T070817-mfa.ts | 39 ++ .../src/database/repos/user/user.repo.ts | 23 +- .../repos/workspace/workspace.repo.ts | 1 + apps/server/src/database/types/db.d.ts | 14 + .../server/src/database/types/entity.types.ts | 6 + apps/server/src/ee | 2 +- package.json | 2 + pnpm-lock.yaml | 123 +++++++ 49 files changed, 2026 insertions(+), 54 deletions(-) create mode 100644 apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx create mode 100644 apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx create mode 100644 apps/client/src/ee/mfa/components/mfa-challenge.module.css create mode 100644 apps/client/src/ee/mfa/components/mfa-challenge.tsx create mode 100644 apps/client/src/ee/mfa/components/mfa-disable-modal.tsx create mode 100644 apps/client/src/ee/mfa/components/mfa-settings.tsx create mode 100644 apps/client/src/ee/mfa/components/mfa-setup-modal.tsx create mode 100644 apps/client/src/ee/mfa/components/mfa-setup-required.tsx create mode 100644 apps/client/src/ee/mfa/components/mfa.module.css create mode 100644 apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts create mode 100644 apps/client/src/ee/mfa/index.ts create mode 100644 apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx create mode 100644 apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx create mode 100644 apps/client/src/ee/mfa/services/mfa-service.ts create mode 100644 apps/client/src/ee/mfa/types/mfa.types.ts create mode 100644 apps/client/src/ee/security/components/enforce-mfa.tsx create mode 100644 apps/client/src/features/user/components/account-mfa-section.tsx create mode 100644 apps/server/src/database/migrations/20250715T070817-mfa.ts diff --git a/apps/client/package.json b/apps/client/package.json index e8001c31..0c9fcc62 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -39,6 +39,7 @@ "jwt-decode": "^4.0.0", "katex": "0.16.22", "lowlight": "^3.3.0", + "mantine-form-zod-resolver": "^1.3.0", "mermaid": "^11.6.0", "mitt": "^3.0.1", "posthog-js": "^1.255.1", diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 6c2289d8..73a8c1fd 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -358,7 +358,7 @@ "{{latestVersion}} is available": "{{latestVersion}} is available", "Default page edit mode": "Default page edit mode", "Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.", - "Reading": "Reading" + "Reading": "Reading", "Delete member": "Delete member", "Member deleted successfully": "Member deleted successfully", "Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.", @@ -402,5 +402,71 @@ "Close (Escape)": "Close (Escape)", "Replace (Enter)": "Replace (Enter)", "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", - "Replace all": "Replace all" + "Replace all": "Replace all", + "Error": "Error", + "Failed to disable MFA": "Failed to disable MFA", + "Disable two-factor authentication": "Disable two-factor authentication", + "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.", + "Please enter your password to disable two-factor authentication:": "Please enter your password to disable two-factor authentication:", + "Two-factor authentication has been enabled": "Two-factor authentication has been enabled", + "Two-factor authentication has been disabled": "Two-factor authentication has been disabled", + "2-step verification": "2-step verification", + "Protect your account with an additional verification layer when signing in.": "Protect your account with an additional verification layer when signing in.", + "Two-factor authentication is active on your account.": "Two-factor authentication is active on your account.", + "Add 2FA method": "Add 2FA method", + "Backup codes": "Backup codes", + "Disable": "Disable", + "Invalid verification code": "Invalid verification code", + "New backup codes have been generated": "New backup codes have been generated", + "Failed to regenerate backup codes": "Failed to regenerate backup codes", + "About backup codes": "About backup codes", + "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.", + "You can regenerate new backup codes at any time. This will invalidate all existing codes.": "You can regenerate new backup codes at any time. This will invalidate all existing codes.", + "Confirm password": "Confirm password", + "Generate new backup codes": "Generate new backup codes", + "Save your new backup codes": "Save your new backup codes", + "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.", + "Your new backup codes": "Your new backup codes", + "I've saved my backup codes": "I've saved my backup codes", + "Failed to setup MFA": "Failed to setup MFA", + "Setup & Verify": "Setup & Verify", + "Add to authenticator": "Add to authenticator", + "1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app", + "Can't scan the code?": "Can't scan the code?", + "Enter this code manually in your authenticator app:": "Enter this code manually in your authenticator app:", + "2. Enter the 6-digit code from your authenticator": "2. Enter the 6-digit code from your authenticator", + "Verify and enable": "Verify and enable", + "Failed to generate QR code. Please try again.": "Failed to generate QR code. Please try again.", + "Backup": "Backup", + "Save codes": "Save codes", + "Save your backup codes": "Save your backup codes", + "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.", + "Print": "Print", + "Two-factor authentication has been set up. Please log in again.": "Two-factor authentication has been set up. Please log in again.", + "Two-Factor authentication required": "Two-factor authentication required", + "Your workspace requires two-factor authentication for all users": "Your workspace requires two-factor authentication for all users", + "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.", + "Set up two-factor authentication": "Set up two-factor authentication", + "Cancel and logout": "Cancel and logout", + "Your workspace requires two-factor authentication. Please set it up to continue.": "Your workspace requires two-factor authentication. Please set it up to continue.", + "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.", + "Password is required": "Password is required", + "Password must be at least 8 characters": "Password must be at least 8 characters", + "Please enter a 6-digit code": "Please enter a 6-digit code", + "Code must be exactly 6 digits": "Code must be exactly 6 digits", + "Enter the 6-digit code found in your authenticator app": "Enter the 6-digit code found in your authenticator app", + "Need help authenticating?": "Need help authenticating?", + "MFA QR Code": "MFA QR Code", + "Account created successfully. Please log in to set up two-factor authentication.": "Account created successfully. Please log in to set up two-factor authentication.", + "Password reset successful. Please log in with your new password and complete two-factor authentication.": "Password reset successful. Please log in with your new password and complete two-factor authentication.", + "Password reset successful. Please log in with your new password to set up two-factor authentication.": "Password reset successful. Please log in with your new password to set up two-factor authentication.", + "Password reset was successful. Please log in with your new password.": "Password reset was successful. Please log in with your new password.", + "Two-factor authentication": "Two-factor authentication", + "Use authenticator app instead": "Use authenticator app instead", + "Verify backup code": "Verify backup code", + "Use backup code": "Use backup code", + "Enter one of your backup codes": "Enter one of your backup codes", + "Backup code": "Backup code", + "Enter one of your backup codes. Each backup code can only be used once.": "Enter one of your backup codes. Each backup code can only be used once.", + "Verify": "Verify" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 6fab378c..29b4bb0e 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -29,8 +29,10 @@ import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-selec import SharedPage from "@/pages/share/shared-page.tsx"; import Shares from "@/pages/settings/shares/shares.tsx"; import ShareLayout from "@/features/share/components/share-layout.tsx"; -import ShareRedirect from '@/pages/share/share-redirect.tsx'; +import ShareRedirect from "@/pages/share/share-redirect.tsx"; import { useTrackOrigin } from "@/hooks/use-track-origin"; +import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; +import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; export default function App() { const { t } = useTranslation(); @@ -45,6 +47,11 @@ export default function App() { } /> } /> } /> + } /> + } + /> {!isCloud() && ( } /> @@ -58,7 +65,10 @@ export default function App() { )} }> - } /> + } + /> } /> diff --git a/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx b/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx new file mode 100644 index 00000000..fef4b8cb --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-backup-code-input.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { + TextInput, + Button, + Stack, + Text, + Alert, +} from "@mantine/core"; +import { IconKey, IconAlertCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; + +interface MfaBackupCodeInputProps { + value: string; + onChange: (value: string) => void; + error?: string; + onSubmit: () => void; + onCancel: () => void; + isLoading?: boolean; +} + +export function MfaBackupCodeInput({ + value, + onChange, + error, + onSubmit, + onCancel, + isLoading, +}: MfaBackupCodeInputProps) { + const { t } = useTranslation(); + + return ( + + } color="blue" variant="light"> + + {t( + "Enter one of your backup codes. Each backup code can only be used once.", + )} + + + + onChange(e.currentTarget.value.toUpperCase())} + error={error} + autoFocus + maxLength={8} + styles={{ + input: { + fontFamily: "monospace", + letterSpacing: "0.1em", + fontSize: "1rem", + }, + }} + /> + + + + + + + + ); +} \ No newline at end of file diff --git a/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx new file mode 100644 index 00000000..fdad6811 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-backup-codes-modal.tsx @@ -0,0 +1,193 @@ +import React, { useState } from "react"; +import { + Modal, + Stack, + Text, + Button, + Paper, + Group, + List, + Code, + CopyButton, + Alert, + PasswordInput, +} from "@mantine/core"; +import { + IconRefresh, + IconCopy, + IconCheck, + IconAlertCircle, +} from "@tabler/icons-react"; +import { useMutation } from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { regenerateBackupCodes } from "@/ee/mfa"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { z } from "zod"; + +interface MfaBackupCodesModalProps { + opened: boolean; + onClose: () => void; +} + +const formSchema = z.object({ + confirmPassword: z.string().min(1, { message: "Password is required" }), +}); + +export function MfaBackupCodesModal({ + opened, + onClose, +}: MfaBackupCodesModalProps) { + const { t } = useTranslation(); + const [backupCodes, setBackupCodes] = useState([]); + const [showNewCodes, setShowNewCodes] = useState(false); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + confirmPassword: "", + }, + }); + + const regenerateMutation = useMutation({ + mutationFn: (data: { confirmPassword: string }) => + regenerateBackupCodes(data), + onSuccess: (data) => { + setBackupCodes(data.backupCodes); + setShowNewCodes(true); + form.reset(); + notifications.show({ + title: t("Success"), + message: t("New backup codes have been generated"), + }); + }, + onError: (error: any) => { + notifications.show({ + title: t("Error"), + message: + error.response?.data?.message || + t("Failed to regenerate backup codes"), + color: "red", + }); + }, + }); + + const handleRegenerate = (values: { confirmPassword: string }) => { + regenerateMutation.mutate(values); + }; + + const handleClose = () => { + setShowNewCodes(false); + setBackupCodes([]); + form.reset(); + onClose(); + }; + + return ( + + + {!showNewCodes ? ( +
+ + } + title={t("About backup codes")} + color="blue" + variant="light" + > + + {t( + "Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.", + )} + + + + + {t( + "You can regenerate new backup codes at any time. This will invalidate all existing codes.", + )} + + + + + + +
+ ) : ( + <> + } + title={t("Save your new backup codes")} + color="yellow" + > + + {t( + "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.", + )} + + + + + + + {t("Your new backup codes")} + + + {({ copied, copy }) => ( + + )} + + + + {backupCodes.map((code, index) => ( + + {code} + + ))} + + + + + + )} +
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.module.css b/apps/client/src/ee/mfa/components/mfa-challenge.module.css new file mode 100644 index 00000000..45eb5df9 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-challenge.module.css @@ -0,0 +1,12 @@ +.container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.paper { + width: 100%; + box-shadow: var(--mantine-shadow-lg); +} \ No newline at end of file diff --git a/apps/client/src/ee/mfa/components/mfa-challenge.tsx b/apps/client/src/ee/mfa/components/mfa-challenge.tsx new file mode 100644 index 00000000..e067d730 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-challenge.tsx @@ -0,0 +1,160 @@ +import React, { useState } from "react"; +import { + Container, + Title, + Text, + PinInput, + Button, + Stack, + Anchor, + Paper, + Center, + ThemeIcon, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { IconDeviceMobile, IconLock } from "@tabler/icons-react"; +import { useNavigate } from "react-router-dom"; +import { notifications } from "@mantine/notifications"; +import classes from "./mfa-challenge.module.css"; +import { verifyMfa } from "@/ee/mfa"; +import APP_ROUTE from "@/lib/app-route"; +import { useTranslation } from "react-i18next"; +import * as z from "zod"; +import { MfaBackupCodeInput } from "./mfa-backup-code-input"; + +const formSchema = z.object({ + code: z + .string() + .refine( + (val) => (val.length === 6 && /^\d{6}$/.test(val)) || val.length === 8, + { + message: "Enter a 6-digit code or 8-character backup code", + }, + ), +}); + +type MfaChallengeFormValues = z.infer; + +export function MfaChallenge() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [useBackupCode, setUseBackupCode] = useState(false); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + code: "", + }, + }); + + const handleSubmit = async (values: MfaChallengeFormValues) => { + setIsLoading(true); + try { + await verifyMfa(values.code); + navigate(APP_ROUTE.HOME); + } catch (error: any) { + setIsLoading(false); + notifications.show({ + message: + error.response?.data?.message || t("Invalid verification code"), + color: "red", + }); + form.setFieldValue("code", ""); + } + }; + + return ( + + + +
+ + + +
+ + + + {t("Two-factor authentication")} + + + {useBackupCode + ? t("Enter one of your backup codes") + : t("Enter the 6-digit code found in your authenticator app")} + + + + {!useBackupCode ? ( +
+ +
+ +
+ {form.errors.code && ( + + {form.errors.code} + + )} + + + + { + setUseBackupCode(true); + form.setFieldValue("code", ""); + form.clearErrors(); + }} + > + {t("Use backup code")} + +
+
+ ) : ( + form.setFieldValue("code", value)} + error={form.errors.code?.toString()} + onSubmit={() => handleSubmit(form.values)} + onCancel={() => { + setUseBackupCode(false); + form.setFieldValue("code", ""); + form.clearErrors(); + }} + isLoading={isLoading} + /> + )} +
+
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx new file mode 100644 index 00000000..4b58f074 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-disable-modal.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { + Modal, + Stack, + Text, + Button, + PasswordInput, + Alert, +} from "@mantine/core"; +import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { useMutation } from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { disableMfa } from "@/ee/mfa"; + +interface MfaDisableModalProps { + opened: boolean; + onClose: () => void; + onComplete: () => void; +} + +const formSchema = z.object({ + confirmPassword: z.string().min(1, { message: "Password is required" }), +}); + +export function MfaDisableModal({ + opened, + onClose, + onComplete, +}: MfaDisableModalProps) { + const { t } = useTranslation(); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + confirmPassword: "", + }, + }); + + const disableMutation = useMutation({ + mutationFn: disableMfa, + onSuccess: () => { + onComplete(); + }, + onError: (error: any) => { + notifications.show({ + title: t("Error"), + message: error.response?.data?.message || t("Failed to disable MFA"), + color: "red", + }); + }, + }); + + const handleSubmit = async (values: { confirmPassword: string }) => { + await disableMutation.mutateAsync(values); + }; + + const handleClose = () => { + form.reset(); + onClose(); + }; + + return ( + +
+ + } + title={t("Warning")} + color="red" + variant="light" + > + + {t( + "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.", + )} + + + + + {t( + "Please enter your password to disable two-factor authentication:", + )} + + + + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-settings.tsx b/apps/client/src/ee/mfa/components/mfa-settings.tsx new file mode 100644 index 00000000..beab11a0 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-settings.tsx @@ -0,0 +1,112 @@ +import React, { useState } from "react"; +import { Group, Text, Button } from "@mantine/core"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { getMfaStatus } from "@/ee/mfa"; +import { MfaSetupModal } from "@/ee/mfa"; +import { MfaDisableModal } from "@/ee/mfa"; +import { MfaBackupCodesModal } from "@/ee/mfa"; + +export function MfaSettings() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [setupModalOpen, setSetupModalOpen] = useState(false); + const [disableModalOpen, setDisableModalOpen] = useState(false); + const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false); + + const { data: mfaStatus, isLoading } = useQuery({ + queryKey: ["mfa-status"], + queryFn: getMfaStatus, + }); + + if (isLoading) { + return null; + } + + // Check if MFA is truly enabled + const isMfaEnabled = mfaStatus?.isEnabled === true; + + const handleSetupComplete = () => { + setSetupModalOpen(false); + queryClient.invalidateQueries({ queryKey: ["mfa-status"] }); + notifications.show({ + title: t("Success"), + message: t("Two-factor authentication has been enabled"), + }); + }; + + const handleDisableComplete = () => { + setDisableModalOpen(false); + queryClient.invalidateQueries({ queryKey: ["mfa-status"] }); + notifications.show({ + title: t("Success"), + message: t("Two-factor authentication has been disabled"), + color: "blue", + }); + }; + + return ( + <> + +
+ {t("2-step verification")} + + {!isMfaEnabled + ? t( + "Protect your account with an additional verification layer when signing in.", + ) + : t("Two-factor authentication is active on your account.")} + +
+ + {!isMfaEnabled ? ( + + ) : ( + + + + + )} +
+ + setSetupModalOpen(false)} + onComplete={handleSetupComplete} + /> + + setDisableModalOpen(false)} + onComplete={handleDisableComplete} + /> + + setBackupCodesModalOpen(false)} + /> + + ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx new file mode 100644 index 00000000..124b622c --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-setup-modal.tsx @@ -0,0 +1,347 @@ +import React, { useState } from "react"; +import { + Modal, + Stack, + Text, + Button, + Group, + Stepper, + Center, + Image, + PinInput, + Alert, + List, + CopyButton, + ActionIcon, + Tooltip, + Paper, + Code, + Loader, + Collapse, + UnstyledButton, +} from "@mantine/core"; +import { + IconQrcode, + IconShieldCheck, + IconKey, + IconCopy, + IconCheck, + IconAlertCircle, + IconChevronDown, + IconChevronRight, + IconPrinter, +} from "@tabler/icons-react"; +import { useForm } from "@mantine/form"; +import { useMutation } from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { setupMfa, enableMfa } from "@/ee/mfa"; +import { zodResolver } from "mantine-form-zod-resolver"; +import { z } from "zod"; + +interface MfaSetupModalProps { + opened: boolean; + onClose?: () => void; + onComplete: () => void; + isRequired?: boolean; +} + +interface SetupData { + secret: string; + qrCode: string; + manualKey: string; +} + +const formSchema = z.object({ + verificationCode: z + .string() + .length(6, { message: "Please enter a 6-digit code" }), +}); + +export function MfaSetupModal({ + opened, + onClose, + onComplete, + isRequired = false, +}: MfaSetupModalProps) { + const { t } = useTranslation(); + const [active, setActive] = useState(0); + const [setupData, setSetupData] = useState(null); + const [backupCodes, setBackupCodes] = useState([]); + const [manualEntryOpen, setManualEntryOpen] = useState(false); + + const form = useForm({ + validate: zodResolver(formSchema), + initialValues: { + verificationCode: "", + }, + }); + + const setupMutation = useMutation({ + mutationFn: () => setupMfa({ method: "totp" }), + onSuccess: (data) => { + setSetupData(data); + }, + onError: (error: any) => { + notifications.show({ + title: t("Error"), + message: error.response?.data?.message || t("Failed to setup MFA"), + color: "red", + }); + }, + }); + + // Generate QR code when modal opens + React.useEffect(() => { + if (opened && !setupData && !setupMutation.isPending) { + setupMutation.mutate(); + } + }, [opened]); + + const enableMutation = useMutation({ + mutationFn: (verificationCode: string) => + enableMfa({ + secret: setupData!.secret, + verificationCode, + }), + onSuccess: (data) => { + setBackupCodes(data.backupCodes); + setActive(1); // Move to backup codes step + }, + onError: (error: any) => { + notifications.show({ + title: t("Error"), + message: + error.response?.data?.message || t("Invalid verification code"), + color: "red", + }); + form.setFieldValue("verificationCode", ""); + }, + }); + + const handleClose = () => { + if (active === 1 && backupCodes.length > 0) { + onComplete(); + } + onClose(); + // Reset state + setTimeout(() => { + setActive(0); + setSetupData(null); + setBackupCodes([]); + setManualEntryOpen(false); + form.reset(); + }, 200); + }; + + const handleVerify = async (values: { verificationCode: string }) => { + await enableMutation.mutateAsync(values.verificationCode); + }; + + const handlePrintBackupCodes = () => { + window.print(); + }; + + return ( + + + } + > +
+ + {setupMutation.isPending ? ( +
+ +
+ ) : setupData ? ( + <> + + {t("1. Scan this QR code with your authenticator app")} + + +
+ + MFA QR Code + +
+ + setManualEntryOpen(!manualEntryOpen)} + > + + {manualEntryOpen ? ( + + ) : ( + + )} + + {t("Can't scan the code?")} + + + + + + } + color="gray" + variant="light" + > + + {t( + "Enter this code manually in your authenticator app:", + )} + + + {setupData.manualKey} + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + + + + + + {t("2. Enter the 6-digit code from your authenticator")} + + + + + {form.errors.verificationCode && ( + + {form.errors.verificationCode} + + )} + + + + + ) : ( +
+ + {t("Failed to generate QR code. Please try again.")} + +
+ )} +
+
+
+ + } + > + + } + title={t("Save your backup codes")} + color="yellow" + > + + {t( + "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.", + )} + + + + + + + {t("Backup codes")} + + + + {({ copied, copy }) => ( + + )} + + + + + + {backupCodes.map((code, index) => ( + + {code} + + ))} + + + + + + +
+
+ ); +} diff --git a/apps/client/src/ee/mfa/components/mfa-setup-required.tsx b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx new file mode 100644 index 00000000..c657abe9 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa-setup-required.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core"; +import { IconAlertCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { MfaSetupModal } from "@/ee/mfa"; +import APP_ROUTE from "@/lib/app-route.ts"; +import { useNavigate } from "react-router-dom"; + +export default function MfaSetupRequired() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleSetupComplete = () => { + navigate(APP_ROUTE.HOME); + }; + + return ( + + + + + {t("Two-factor authentication required")} + + + } color="yellow"> + + {t( + "Your workspace requires two-factor authentication. Please set it up to continue.", + )} + + + + + {t( + "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.", + )} + + + + + + + ); +} diff --git a/apps/client/src/ee/mfa/components/mfa.module.css b/apps/client/src/ee/mfa/components/mfa.module.css new file mode 100644 index 00000000..535704a5 --- /dev/null +++ b/apps/client/src/ee/mfa/components/mfa.module.css @@ -0,0 +1,31 @@ +.qrCodeContainer { + background-color: white; + padding: 1rem; + border-radius: var(--mantine-radius-md); + display: inline-block; +} + +.backupCodesList { + font-family: var(--mantine-font-family-monospace); + background-color: var(--mantine-color-gray-0); + padding: 1rem; + border-radius: var(--mantine-radius-md); + + @mixin dark { + background-color: var(--mantine-color-dark-7); + } +} + +.codeItem { + padding: 0.25rem 0; + font-size: 0.875rem; +} + +.setupStep { + min-height: 400px; +} + +.verificationInput { + max-width: 320px; + margin: 0 auto; +} \ No newline at end of file diff --git a/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts new file mode 100644 index 00000000..9200cac7 --- /dev/null +++ b/apps/client/src/ee/mfa/hooks/use-mfa-page-protection.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import APP_ROUTE from "@/lib/app-route"; +import { validateMfaAccess } from "@/ee/mfa"; + +export function useMfaPageProtection() { + const navigate = useNavigate(); + const location = useLocation(); + const [isValidating, setIsValidating] = useState(true); + const [isValid, setIsValid] = useState(false); + + useEffect(() => { + const checkAccess = async () => { + const result = await validateMfaAccess(); + + if (!result.valid) { + navigate(APP_ROUTE.AUTH.LOGIN); + return; + } + + // Check if user is on the correct page based on their MFA state + const isOnChallengePage = + location.pathname === APP_ROUTE.AUTH.MFA_CHALLENGE; + const isOnSetupPage = + location.pathname === APP_ROUTE.AUTH.MFA_SETUP_REQUIRED; + + if (result.requiresMfaSetup && !isOnSetupPage) { + // User needs to set up MFA but is on challenge page + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + } else if ( + !result.requiresMfaSetup && + result.userHasMfa && + !isOnChallengePage + ) { + // User has MFA and should be on challenge page + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + } else if (!result.isTransferToken) { + // User has a regular auth token, shouldn't be on MFA pages + navigate(APP_ROUTE.HOME); + } else { + setIsValid(true); + } + + setIsValidating(false); + }; + + checkAccess(); + }, [navigate, location.pathname]); + + return { isValidating, isValid }; +} diff --git a/apps/client/src/ee/mfa/index.ts b/apps/client/src/ee/mfa/index.ts new file mode 100644 index 00000000..047b0a8d --- /dev/null +++ b/apps/client/src/ee/mfa/index.ts @@ -0,0 +1,19 @@ +// Components +export { MfaChallenge } from "./components/mfa-challenge"; +export { MfaSettings } from "./components/mfa-settings"; +export { MfaSetupModal } from "./components/mfa-setup-modal"; +export { MfaDisableModal } from "./components/mfa-disable-modal"; +export { MfaBackupCodesModal } from "./components/mfa-backup-codes-modal"; + +// Pages +export { MfaChallengePage } from "./pages/mfa-challenge-page"; +export { MfaSetupRequiredPage } from "./pages/mfa-setup-required-page"; + +// Services +export * from "./services/mfa-service"; + +// Types +export * from "./types/mfa.types"; + +// Hooks +export { useMfaPageProtection } from "./hooks/use-mfa-page-protection.ts"; diff --git a/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx b/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx new file mode 100644 index 00000000..40949fc7 --- /dev/null +++ b/apps/client/src/ee/mfa/pages/mfa-challenge-page.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { MfaChallenge } from "@/ee/mfa"; +import { useMfaPageProtection } from "@/ee/mfa"; + +export function MfaChallengePage() { + const { isValid } = useMfaPageProtection(); + + if (!isValid) { + return null; + } + + return ; +} diff --git a/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx b/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx new file mode 100644 index 00000000..0b5f756d --- /dev/null +++ b/apps/client/src/ee/mfa/pages/mfa-setup-required-page.tsx @@ -0,0 +1,113 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Container, + Title, + Text, + Button, + Stack, + Paper, + Alert, + Center, + ThemeIcon, +} from "@mantine/core"; +import { IconShieldCheck, IconAlertCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import APP_ROUTE from "@/lib/app-route"; +import { MfaSetupModal } from "@/ee/mfa"; +import classes from "@/features/auth/components/auth.module.css"; +import { notifications } from "@mantine/notifications"; +import { useMfaPageProtection } from "@/ee/mfa"; + +export function MfaSetupRequiredPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [setupModalOpen, setSetupModalOpen] = useState(false); + const { isValid } = useMfaPageProtection(); + + const handleSetupComplete = async () => { + setSetupModalOpen(false); + + notifications.show({ + title: t("Success"), + message: t( + "Two-factor authentication has been set up. Please log in again.", + ), + }); + + navigate(APP_ROUTE.AUTH.LOGIN); + }; + + const handleLogout = () => { + navigate(APP_ROUTE.AUTH.LOGIN); + }; + + if (!isValid) { + return null; + } + + return ( + + + +
+ + + +
+ + + + {t("Two-factor authentication required")} + + + {t( + "Your workspace requires two-factor authentication for all users", + )} + + + + } + color="blue" + variant="light" + w="100%" + > + + {t( + "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.", + )} + + + + + + + + +
+
+ + setSetupModalOpen(false)} + onComplete={handleSetupComplete} + isRequired={true} + /> +
+ ); +} diff --git a/apps/client/src/ee/mfa/services/mfa-service.ts b/apps/client/src/ee/mfa/services/mfa-service.ts new file mode 100644 index 00000000..bf49d2eb --- /dev/null +++ b/apps/client/src/ee/mfa/services/mfa-service.ts @@ -0,0 +1,61 @@ +import api from "@/lib/api-client"; +import { + MfaBackupCodesResponse, + MfaDisableRequest, + MfaEnableRequest, + MfaEnableResponse, + MfaSetupRequest, + MfaSetupResponse, + MfaStatusResponse, + MfaAccessValidationResponse, +} from "@/ee/mfa"; + +export async function getMfaStatus(): Promise { + const req = await api.post("/mfa/status"); + return req.data; +} + +export async function setupMfa( + data: MfaSetupRequest, +): Promise { + const req = await api.post("/mfa/setup", data); + return req.data; +} + +export async function enableMfa( + data: MfaEnableRequest, +): Promise { + const req = await api.post("/mfa/enable", data); + return req.data; +} + +export async function disableMfa( + data: MfaDisableRequest, +): Promise<{ success: boolean }> { + const req = await api.post<{ success: boolean }>("/mfa/disable", data); + return req.data; +} + +export async function regenerateBackupCodes(data: { + confirmPassword: string; +}): Promise { + const req = await api.post( + "/mfa/generate-backup-codes", + data, + ); + return req.data; +} + +export async function verifyMfa(code: string): Promise { + const req = await api.post("/mfa/verify", { code }); + return req.data; +} + +export async function validateMfaAccess(): Promise { + try { + const res = await api.post("/mfa/validate-access"); + return res.data; + } catch { + return { valid: false }; + } +} diff --git a/apps/client/src/ee/mfa/types/mfa.types.ts b/apps/client/src/ee/mfa/types/mfa.types.ts new file mode 100644 index 00000000..ac032195 --- /dev/null +++ b/apps/client/src/ee/mfa/types/mfa.types.ts @@ -0,0 +1,62 @@ +export interface MfaMethod { + type: 'totp' | 'email'; + isEnabled: boolean; +} + +export interface MfaSettings { + isEnabled: boolean; + methods: MfaMethod[]; + backupCodesCount: number; + lastUpdated?: string; +} + +export interface MfaSetupState { + method: 'totp' | 'email'; + secret?: string; + qrCode?: string; + manualEntry?: string; + backupCodes?: string[]; +} + +export interface MfaStatusResponse { + isEnabled?: boolean; + method?: string | null; + backupCodesCount?: number; +} + +export interface MfaSetupRequest { + method: 'totp'; +} + +export interface MfaSetupResponse { + method: string; + qrCode: string; + secret: string; + manualKey: string; +} + +export interface MfaEnableRequest { + secret: string; + verificationCode: string; +} + +export interface MfaEnableResponse { + success: boolean; + backupCodes: string[]; +} + +export interface MfaDisableRequest { + confirmPassword: string; +} + +export interface MfaBackupCodesResponse { + backupCodes: string[]; +} + +export interface MfaAccessValidationResponse { + valid: boolean; + isTransferToken?: boolean; + requiresMfaSetup?: boolean; + userHasMfa?: boolean; + isMfaEnforced?: boolean; +} diff --git a/apps/client/src/ee/security/components/enforce-mfa.tsx b/apps/client/src/ee/security/components/enforce-mfa.tsx new file mode 100644 index 00000000..37cf5152 --- /dev/null +++ b/apps/client/src/ee/security/components/enforce-mfa.tsx @@ -0,0 +1,66 @@ +import { Group, Text, Switch, MantineSize, Title } 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"; + +export default function EnforceMfa() { + const { t } = useTranslation(); + + return ( + <> + + MFA + + +
+ {t("Enforce two-factor authentication")} + + {t( + "Once enforced, all members must enable two-factor authentication to access the workspace.", + )} + +
+ + +
+ + ); +} + +interface EnforceMfaToggleProps { + size?: MantineSize; + label?: string; +} +export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState(workspace?.enforceMfa); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ enforceMfa: value }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + ); +} diff --git a/apps/client/src/ee/security/pages/security.tsx b/apps/client/src/ee/security/pages/security.tsx index de8efc06..82d8640f 100644 --- a/apps/client/src/ee/security/pages/security.tsx +++ b/apps/client/src/ee/security/pages/security.tsx @@ -11,6 +11,7 @@ import AllowedDomains from "@/ee/security/components/allowed-domains.tsx"; import { useTranslation } from "react-i18next"; import useLicense from "@/ee/hooks/use-license.tsx"; import usePlan from "@/ee/hooks/use-plan.tsx"; +import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; export default function Security() { const { t } = useTranslation(); @@ -33,6 +34,10 @@ export default function Security() { + + + + Single sign-on (SSO) diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx index 2d7b3657..37397ef8 100644 --- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx +++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import * as z from "zod"; -import { useForm, zodResolver } from "@mantine/form"; +import { useForm } from "@mantine/form"; import { Container, Title, @@ -11,6 +11,7 @@ import { Box, Stack, } from "@mantine/core"; +import { zodResolver } from "mantine-form-zod-resolver"; import { useParams, useSearchParams } from "react-router-dom"; import { IRegister } from "@/features/auth/types/auth.types"; import useAuth from "@/features/auth/hooks/use-auth"; diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 2867f238..decb393f 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 from "@/lib/app-route.ts"; import { RESET } from "jotai/utils"; import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; -import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts"; +import { exchangeTokenRedirectUrl } from "@/ee/utils.ts"; export default function useAuth() { const { t } = useTranslation(); @@ -39,9 +39,17 @@ export default function useAuth() { setIsLoading(true); try { - await login(data); + const response = await login(data); setIsLoading(false); - navigate(APP_ROUTE.HOME); + + // Check if MFA is required + if (response?.userHasMfa) { + navigate(APP_ROUTE.AUTH.MFA_CHALLENGE); + } else if (response?.requiresMfaSetup) { + navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED); + } else { + navigate(APP_ROUTE.HOME); + } } catch (err) { setIsLoading(false); console.log(err); @@ -56,9 +64,19 @@ export default function useAuth() { setIsLoading(true); try { - await acceptInvitation(data); + const response = await acceptInvitation(data); setIsLoading(false); - navigate(APP_ROUTE.HOME); + + if (response?.requiresLogin) { + notifications.show({ + message: t( + "Account created successfully. Please log in to set up two-factor authentication.", + ), + }); + navigate(APP_ROUTE.AUTH.LOGIN); + } else { + navigate(APP_ROUTE.HOME); + } } catch (err) { setIsLoading(false); notifications.show({ @@ -100,12 +118,22 @@ export default function useAuth() { setIsLoading(true); try { - await passwordReset(data); + const response = await passwordReset(data); setIsLoading(false); - navigate(APP_ROUTE.HOME); - notifications.show({ - message: t("Password reset was successful"), - }); + + if (response?.requiresLogin) { + notifications.show({ + message: t( + "Password reset was successful. Please log in with your new password.", + ), + }); + navigate(APP_ROUTE.AUTH.LOGIN); + } else { + navigate(APP_ROUTE.HOME); + notifications.show({ + message: t("Password reset was successful"), + }); + } } catch (err) { setIsLoading(false); notifications.show({ diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index 2008ecfc..20e437f3 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -4,14 +4,16 @@ import { ICollabToken, IForgotPassword, ILogin, + ILoginResponse, IPasswordReset, ISetupWorkspace, IVerifyUserToken, } from "@/features/auth/types/auth.types"; import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; -export async function login(data: ILogin): Promise { - await api.post("/auth/login", data); +export async function login(data: ILogin): Promise { + const response = await api.post("/auth/login", data); + return response.data; } export async function logout(): Promise { @@ -36,8 +38,9 @@ export async function forgotPassword(data: IForgotPassword): Promise { await api.post("/auth/forgot-password", data); } -export async function passwordReset(data: IPasswordReset): Promise { - await api.post("/auth/password-reset", data); +export async function passwordReset(data: IPasswordReset): Promise<{ requiresLogin?: boolean; }> { + const req = await api.post("/auth/password-reset", data); + return req.data; } export async function verifyUserToken(data: IVerifyUserToken): Promise { @@ -47,4 +50,4 @@ 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/auth/types/auth.types.ts b/apps/client/src/features/auth/types/auth.types.ts index 6a925a0a..71abc6b7 100644 --- a/apps/client/src/features/auth/types/auth.types.ts +++ b/apps/client/src/features/auth/types/auth.types.ts @@ -38,3 +38,10 @@ export interface IVerifyUserToken { export interface ICollabToken { token?: string; } + +export interface ILoginResponse { + userHasMfa?: boolean; + requiresMfaSetup?: boolean; + mfaToken?: string; + isMfaEnforced?: boolean; +} diff --git a/apps/client/src/features/user/components/account-mfa-section.tsx b/apps/client/src/features/user/components/account-mfa-section.tsx new file mode 100644 index 00000000..a2709afd --- /dev/null +++ b/apps/client/src/features/user/components/account-mfa-section.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { isCloud } from "@/lib/config"; +import { useLicense } from "@/ee/hooks/use-license"; +import { MfaSettings } from "@/ee/mfa"; + +export function AccountMfaSection() { + const { hasLicenseKey } = useLicense(); + const showMfa = isCloud() || hasLicenseKey; + + if (!showMfa) { + return null; + } + + return ; +} diff --git a/apps/client/src/features/user/components/change-email.tsx b/apps/client/src/features/user/components/change-email.tsx index 873d0744..5e00cbba 100644 --- a/apps/client/src/features/user/components/change-email.tsx +++ b/apps/client/src/features/user/components/change-email.tsx @@ -22,7 +22,7 @@ export default function ChangeEmail() { return ( -
+
{t("Email")} {currentUser?.user.email} @@ -30,7 +30,7 @@ export default function ChangeEmail() {
{/* - */} diff --git a/apps/client/src/features/user/components/change-password.tsx b/apps/client/src/features/user/components/change-password.tsx index 1dddfe1e..63eb25b4 100644 --- a/apps/client/src/features/user/components/change-password.tsx +++ b/apps/client/src/features/user/components/change-password.tsx @@ -14,14 +14,14 @@ export default function ChangePassword() { return ( -
+
{t("Password")} {t("You can change your password here.")}
- diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 293629fe..dd404806 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -66,8 +66,9 @@ export async function createInvitation(data: ICreateInvite) { return req.data; } -export async function acceptInvitation(data: IAcceptInvite): Promise { - await api.post("/workspace/invites/accept", data); +export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> { + const req = await api.post("/workspace/invites/accept", data); + return req.data; } export async function getInviteLink(data: { diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index c9df7f19..600641c9 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -21,6 +21,7 @@ export interface IWorkspace { memberCount?: number; plan?: string; hasLicenseKey?: boolean; + enforceMfa?: boolean; } export interface ICreateInvite { diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index 56dac67a..7b95d5c4 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -8,6 +8,8 @@ const APP_ROUTE = { PASSWORD_RESET: "/password-reset", CREATE_WORKSPACE: "/create", SELECT_WORKSPACE: "/select", + MFA_CHALLENGE: "/login/mfa", + MFA_SETUP_REQUIRED: "/login/mfa/setup", }, SETTINGS: { ACCOUNT: { diff --git a/apps/client/src/pages/settings/account/account-settings.tsx b/apps/client/src/pages/settings/account/account-settings.tsx index c1fd6fdc..f1d78f7d 100644 --- a/apps/client/src/pages/settings/account/account-settings.tsx +++ b/apps/client/src/pages/settings/account/account-settings.tsx @@ -4,18 +4,21 @@ import ChangePassword from "@/features/user/components/change-password"; import { Divider } from "@mantine/core"; import AccountAvatar from "@/features/user/components/account-avatar"; import SettingsTitle from "@/components/settings/settings-title.tsx"; -import {getAppName} from "@/lib/config.ts"; -import {Helmet} from "react-helmet-async"; +import { getAppName } from "@/lib/config.ts"; +import { Helmet } from "react-helmet-async"; import { useTranslation } from "react-i18next"; +import { AccountMfaSection } from "@/features/user/components/account-mfa-section"; export default function AccountSettings() { const { t } = useTranslation(); return ( <> - - {t("My Profile")} - {getAppName()} - + + + {t("My Profile")} - {getAppName()} + + @@ -29,6 +32,10 @@ export default function AccountSettings() { + + + + ); } diff --git a/apps/server/package.json b/apps/server/package.json index 4106ba89..1240e5cc 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -71,6 +71,7 @@ "nestjs-kysely": "^1.2.0", "nodemailer": "^7.0.3", "openid-client": "^5.7.1", + "otpauth": "^9.4.0", "p-limit": "^6.2.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 87448bc7..edd9a903 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as bcrypt from 'bcrypt'; import { sanitize } from 'sanitize-filename-ts'; +import { FastifyRequest } from 'fastify'; export const envPath = path.resolve(process.cwd(), '..', '..', '.env'); @@ -74,3 +75,10 @@ export function sanitizeFileName(fileName: string): string { const sanitizedFilename = sanitize(fileName).replace(/ /g, '_'); return sanitizedFilename.slice(0, 255); } + +export function extractBearerTokenFromHeader( + request: FastifyRequest, +): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; +} diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index dc1235ec..a11e0360 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -6,6 +6,7 @@ import { Post, Res, UseGuards, + Logger, } from '@nestjs/common'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './services/auth.service'; @@ -22,12 +23,16 @@ import { PasswordResetDto } from './dto/password-reset.dto'; import { VerifyUserTokenDto } from './dto/verify-user-token.dto'; import { FastifyReply } from 'fastify'; import { validateSsoEnforcement } from './auth.util'; +import { ModuleRef } from '@nestjs/core'; @Controller('auth') export class AuthController { + private readonly logger = new Logger(AuthController.name); + constructor( private authService: AuthService, private environmentService: EnvironmentService, + private moduleRef: ModuleRef, ) {} @HttpCode(HttpStatus.OK) @@ -39,6 +44,45 @@ export class AuthController { ) { validateSsoEnforcement(workspace); + let MfaModule: any; + let isMfaModuleReady = false; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + MfaModule = require('./../../ee/mfa/services/mfa.service'); + isMfaModuleReady = true; + } catch (err) { + this.logger.debug( + 'MFA module requested but EE module not bundled in this build', + ); + isMfaModuleReady = false; + } + if (isMfaModuleReady) { + const mfaService = this.moduleRef.get(MfaModule.MfaService, { + strict: false, + }); + + const mfaResult = await mfaService.checkMfaRequirements( + loginInput, + workspace, + res, + ); + + if (mfaResult) { + // If user has MFA enabled OR workspace enforces MFA, require MFA verification + if (mfaResult.userHasMfa || mfaResult.requiresMfaSetup) { + return { + userHasMfa: mfaResult.userHasMfa, + requiresMfaSetup: mfaResult.requiresMfaSetup, + isMfaEnforced: mfaResult.isMfaEnforced, + }; + } else if (mfaResult.authToken) { + // User doesn't have MFA and workspace doesn't require it + this.setAuthCookie(res, mfaResult.authToken); + return; + } + } + } + const authToken = await this.authService.login(loginInput, workspace.id); this.setAuthCookie(res, authToken); } @@ -85,11 +129,22 @@ export class AuthController { @Body() passwordResetDto: PasswordResetDto, @AuthWorkspace() workspace: Workspace, ) { - const authToken = await this.authService.passwordReset( + const result = await this.authService.passwordReset( passwordResetDto, - workspace.id, + workspace, ); - this.setAuthCookie(res, authToken); + + if (result.requiresLogin) { + return { + requiresLogin: true, + }; + } + + // Set auth cookie if no MFA is required + this.setAuthCookie(res, result.authToken); + return { + requiresLogin: false, + }; } @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index b9ce13c4..06322712 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -3,6 +3,7 @@ export enum JwtType { COLLAB = 'collab', EXCHANGE = 'exchange', ATTACHMENT = 'attachment', + MFA_TOKEN = 'mfa_token', } export type JwtPayload = { sub: string; @@ -30,3 +31,8 @@ export type JwtAttachmentPayload = { type: 'attachment'; }; +export interface JwtMfaTokenPayload { + sub: string; + workspaceId: string; + type: 'mfa_token'; +} diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index c71bc3bc..72ffc521 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -47,7 +47,7 @@ export class AuthService { includePassword: true, }); - const errorMessage = 'email or password does not match'; + const errorMessage = 'Email or password does not match'; if (!user || user?.deletedAt) { throw new UnauthorizedException(errorMessage); } @@ -156,10 +156,13 @@ export class AuthService { }); } - async passwordReset(passwordResetDto: PasswordResetDto, workspaceId: string) { + async passwordReset( + passwordResetDto: PasswordResetDto, + workspace: Workspace, + ) { const userToken = await this.userTokenRepo.findById( passwordResetDto.token, - workspaceId, + workspace.id, ); if ( @@ -170,7 +173,9 @@ export class AuthService { throw new BadRequestException('Invalid or expired token'); } - const user = await this.userRepo.findById(userToken.userId, workspaceId); + const user = await this.userRepo.findById(userToken.userId, workspace.id, { + includeUserMfa: true, + }); if (!user || user.deletedAt) { throw new NotFoundException('User not found'); } @@ -183,7 +188,7 @@ export class AuthService { password: newPasswordHash, }, user.id, - workspaceId, + workspace.id, trx, ); @@ -201,7 +206,18 @@ export class AuthService { template: emailTemplate, }); - return this.tokenService.generateAccessToken(user); + // Check if user has MFA enabled or workspace enforces MFA + const userHasMfa = user?.['mfa']?.isEnabled || false; + const workspaceEnforcesMfa = workspace.enforceMfa || false; + + if (userHasMfa || workspaceEnforcesMfa) { + return { + requiresLogin: true, + }; + } + + const authToken = await this.tokenService.generateAccessToken(user); + return { authToken }; } async verifyUserToken( diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts index c0e64e25..f1c4c5c0 100644 --- a/apps/server/src/core/auth/services/token.service.ts +++ b/apps/server/src/core/auth/services/token.service.ts @@ -9,6 +9,7 @@ import { JwtAttachmentPayload, JwtCollabPayload, JwtExchangePayload, + JwtMfaTokenPayload, JwtPayload, JwtType, } from '../dto/jwt-payload'; @@ -76,6 +77,22 @@ export class TokenService { return this.jwtService.sign(payload, { expiresIn: '1h' }); } + async generateMfaToken( + user: User, + workspaceId: string, + ): Promise { + if (user.deactivatedAt || user.deletedAt) { + throw new ForbiddenException(); + } + + const payload: JwtMfaTokenPayload = { + sub: user.id, + workspaceId, + type: JwtType.MFA_TOKEN, + }; + return this.jwtService.sign(payload, { expiresIn: '5m' }); + } + async verifyJwt(token: string, tokenType: string) { const payload = await this.jwtService.verifyAsync(token, { secret: this.environmentService.getAppSecret(), diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index fae56b7c..c31a597b 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -6,6 +6,7 @@ import { JwtPayload, JwtType } from '../dto/jwt-payload'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { FastifyRequest } from 'fastify'; +import { extractBearerTokenFromHeader } from '../../../common/helpers'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -18,7 +19,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ) { super({ jwtFromRequest: (req: FastifyRequest) => { - return req.cookies?.authToken || this.extractTokenFromHeader(req); + return req.cookies?.authToken || extractBearerTokenFromHeader(req); }, ignoreExpiration: false, secretOrKey: environmentService.getAppSecret(), @@ -48,9 +49,4 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { return { user, workspace }; } - - private extractTokenFromHeader(request: FastifyRequest): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } } diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index 47a78480..f9062878 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -29,7 +29,8 @@ import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.fact import { WorkspaceCaslAction, WorkspaceCaslSubject, -} from '../../casl/interfaces/workspace-ability.type';import { FastifyReply } from 'fastify'; +} from '../../casl/interfaces/workspace-ability.type'; +import { FastifyReply } from 'fastify'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { CheckHostnameDto } from '../dto/check-hostname.dto'; import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto'; @@ -257,17 +258,27 @@ export class WorkspaceController { @AuthWorkspace() workspace: Workspace, @Res({ passthrough: true }) res: FastifyReply, ) { - const authToken = await this.workspaceInvitationService.acceptInvitation( + const result = await this.workspaceInvitationService.acceptInvitation( acceptInviteDto, workspace, ); - res.setCookie('authToken', authToken, { + if (result.requiresLogin) { + return { + requiresLogin: true, + }; + } + + res.setCookie('authToken', result.authToken, { httpOnly: true, path: '/', expires: this.environmentService.getCookieExpiresIn(), secure: this.environmentService.isHttps(), }); + + return { + requiresLogin: false, + }; } @Public() diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 412a3a8c..a0182a77 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -14,4 +14,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsBoolean() enforceSso: boolean; + + @IsOptional() + @IsBoolean() + enforceMfa: boolean; } diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts index 5ecc8427..90485f0a 100644 --- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts +++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts @@ -177,7 +177,14 @@ export class WorkspaceInvitationService { } } - async acceptInvitation(dto: AcceptInviteDto, workspace: Workspace) { + async acceptInvitation( + dto: AcceptInviteDto, + workspace: Workspace, + ): Promise<{ + authToken?: string; + requiresLogin?: boolean; + message?: string; + }> { const invitation = await this.db .selectFrom('workspaceInvitations') .selectAll() @@ -289,7 +296,14 @@ export class WorkspaceInvitationService { }); } - return this.tokenService.generateAccessToken(newUser); + if (workspace.enforceMfa) { + return { + requiresLogin: true, + }; + } + + const authToken = await this.tokenService.generateAccessToken(newUser); + return { authToken }; } async resendInvitation( diff --git a/apps/server/src/database/migrations/20250715T070817-mfa.ts b/apps/server/src/database/migrations/20250715T070817-mfa.ts new file mode 100644 index 00000000..8aa6a92c --- /dev/null +++ b/apps/server/src/database/migrations/20250715T070817-mfa.ts @@ -0,0 +1,39 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('user_mfa') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('user_id', 'uuid', (col) => + col.references('users.id').onDelete('cascade').notNull(), + ) + .addColumn('method', 'varchar', (col) => col.notNull().defaultTo('totp')) + .addColumn('secret', 'text', (col) => col) + .addColumn('is_enabled', 'boolean', (col) => col.defaultTo(false)) + .addColumn('backup_codes', sql`text[]`, (col) => col) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('user_mfa_user_id_unique', ['user_id']) + .execute(); + + // Add MFA policy columns to workspaces + await db.schema + .alterTable('workspaces') + .addColumn('enforce_mfa', 'boolean', (col) => col.defaultTo(false)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('workspaces').dropColumn('enforce_mfa').execute(); + + await db.schema.dropTable('user_mfa').execute(); +} diff --git a/apps/server/src/database/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts index f87f4daa..190670e3 100644 --- a/apps/server/src/database/repos/user/user.repo.ts +++ b/apps/server/src/database/repos/user/user.repo.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectKysely } from 'nestjs-kysely'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; -import { Users } from '@docmost/db/types/db'; +import { DB, Users } from '@docmost/db/types/db'; import { hashPassword } from '../../../common/helpers'; import { dbOrTx } from '@docmost/db/utils'; import { @@ -11,7 +11,8 @@ import { } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '../../pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; -import { sql } from 'kysely'; +import { ExpressionBuilder, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; @Injectable() export class UserRepo { @@ -40,6 +41,7 @@ export class UserRepo { workspaceId: string, opts?: { includePassword?: boolean; + includeUserMfa?: boolean; trx?: KyselyTransaction; }, ): Promise { @@ -48,6 +50,7 @@ export class UserRepo { .selectFrom('users') .select(this.baseFields) .$if(opts?.includePassword, (qb) => qb.select('password')) + .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) .where('id', '=', userId) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -58,6 +61,7 @@ export class UserRepo { workspaceId: string, opts?: { includePassword?: boolean; + includeUserMfa?: boolean; trx?: KyselyTransaction; }, ): Promise { @@ -66,6 +70,7 @@ export class UserRepo { .selectFrom('users') .select(this.baseFields) .$if(opts?.includePassword, (qb) => qb.select('password')) + .$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa)) .where(sql`LOWER(email)`, '=', sql`LOWER(${email})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -177,4 +182,18 @@ export class UserRepo { .returning(this.baseFields) .executeTakeFirst(); } + + withUserMfa(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('userMfa') + .select([ + 'userMfa.id', + 'userMfa.method', + 'userMfa.isEnabled', + 'userMfa.createdAt', + ]) + .whereRef('userMfa.userId', '=', 'users.id'), + ).as('mfa'); + } } diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index 8b9765f4..6a15fcd9 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -32,6 +32,7 @@ export class WorkspaceRepo { 'trialEndAt', 'enforceSso', 'plan', + 'enforceMfa', ]; constructor(@InjectKysely() private readonly db: KyselyDB) {} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index b49f15b0..1d2051d4 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -247,6 +247,18 @@ export interface Spaces { workspaceId: string; } +export interface UserMfa { + backupCodes: string[] | null; + createdAt: Generated; + id: Generated; + isEnabled: Generated; + method: Generated; + secret: string | null; + updatedAt: Generated; + userId: string; + workspaceId: string; +} + export interface Users { avatarUrl: string | null; createdAt: Generated; @@ -300,6 +312,7 @@ export interface Workspaces { deletedAt: Timestamp | null; description: string | null; emailDomains: Generated; + enforceMfa: Generated; enforceSso: Generated; hostname: string | null; id: Generated; @@ -329,6 +342,7 @@ export interface DB { shares: Shares; spaceMembers: SpaceMembers; spaces: Spaces; + userMfa: UserMfa; users: Users; userTokens: UserTokens; workspaceInvitations: WorkspaceInvitations; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index db2c2823..b23fa775 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -18,6 +18,7 @@ import { AuthAccounts, Shares, FileTasks, + UserMfa as _UserMFA, } from './db'; // Workspace @@ -113,3 +114,8 @@ export type UpdatableShare = Updateable>; export type FileTask = Selectable; export type InsertableFileTask = Insertable; export type UpdatableFileTask = Updateable>; + +// UserMFA +export type UserMFA = Selectable<_UserMFA>; +export type InsertableUserMFA = Insertable<_UserMFA>; +export type UpdatableUserMFA = Updateable>; diff --git a/apps/server/src/ee b/apps/server/src/ee index 49a16ab3..0a2b9a0d 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 49a16ab3e03971a375bcbfac60c3c1150d19059b +Subproject commit 0a2b9a0dbc54036009d74d39681f33902b307968 diff --git a/package.json b/package.json index f994f986..63042801 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@tiptap/react": "^2.10.3", "@tiptap/starter-kit": "^2.10.3", "@tiptap/suggestion": "^2.10.3", + "@types/qrcode": "^1.5.5", "bytes": "^3.1.2", "cross-env": "^7.0.3", "date-fns": "^4.1.0", @@ -70,6 +71,7 @@ "linkifyjs": "^4.2.0", "marked": "13.0.3", "ms": "3.0.0-canary.1", + "qrcode": "^1.5.4", "uuid": "^11.1.0", "y-indexeddb": "^9.0.12", "yjs": "^13.6.27" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c7a990b..ccaec09b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: '@tiptap/suggestion': specifier: ^2.10.3 version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 bytes: specifier: ^3.1.2 version: 3.1.2 @@ -172,6 +175,9 @@ importers: ms: specifier: 3.0.0-canary.1 version: 3.0.0-canary.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 uuid: specifier: ^11.1.0 version: 11.1.0 @@ -290,6 +296,9 @@ importers: lowlight: specifier: ^3.3.0 version: 3.3.0 + mantine-form-zod-resolver: + specifier: ^1.3.0 + version: 1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56) mermaid: specifier: ^11.6.0 version: 11.6.0 @@ -534,6 +543,9 @@ importers: openid-client: specifier: ^5.7.1 version: 5.7.1 + otpauth: + specifier: ^9.4.0 + version: 9.4.0 p-limit: specifier: ^6.2.0 version: 6.2.0 @@ -2846,6 +2858,10 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.7.1': + resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} + engines: {node: ^14.21.3 || >=16} + '@node-saml/node-saml@5.0.1': resolution: {integrity: sha512-YQzFPEC+CnsfO9AFYnwfYZKIzOLx3kITaC1HrjHVLTo6hxcQhc+LgHODOMvW4VCV95Gwrz1MshRUWCPzkDqmnA==} engines: {node: '>= 18'} @@ -4345,6 +4361,9 @@ packages: '@types/prop-types@15.7.11': resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} + '@types/qrcode@1.5.5': + resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} + '@types/qs@6.9.14': resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==} @@ -5092,6 +5111,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -5518,6 +5540,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -5617,6 +5643,9 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dnd-core@14.0.1: resolution: {integrity: sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==} @@ -7161,6 +7190,13 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + mantine-form-zod-resolver@1.3.0: + resolution: {integrity: sha512-XlXXkJCYuUuOllW0zedYW+m/lbdFQ/bso1Vz+pJOYkxgjhoGvzN2EXWCS2+0iTOT9Q7WnOwWvHmvpTJN3PxSXw==} + engines: {node: '>=16.6.0'} + peerDependencies: + '@mantine/form': '>=7.0.0' + zod: '>=3.25.0' + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -7632,6 +7668,9 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + otpauth@9.4.0: + resolution: {integrity: sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -7878,6 +7917,10 @@ packages: png-chunks-extract@1.0.0: resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -8118,6 +8161,11 @@ packages: pwacompat@2.0.17: resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.12.0: resolution: {integrity: sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==} engines: {node: '>=0.6'} @@ -8387,6 +8435,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -9392,6 +9443,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.16: resolution: {integrity: sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==} engines: {node: '>= 0.4'} @@ -9531,6 +9585,9 @@ packages: peerDependencies: yjs: ^13.0.0 + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -9550,10 +9607,18 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -12539,6 +12604,8 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.10': optional: true + '@noble/hashes@1.7.1': {} + '@node-saml/node-saml@5.0.1': dependencies: '@types/debug': 4.1.12 @@ -14169,6 +14236,10 @@ snapshots: '@types/prop-types@15.7.11': {} + '@types/qrcode@1.5.5': + dependencies: + '@types/node': 22.13.4 + '@types/qs@6.9.14': {} '@types/range-parser@1.2.7': {} @@ -15124,6 +15195,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -15566,6 +15643,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.4.3: {} decode-named-character-reference@1.1.0: @@ -15644,6 +15723,8 @@ snapshots: diff@5.2.0: {} + dijkstrajs@1.0.3: {} + dnd-core@14.0.1: dependencies: '@react-dnd/asap': 4.0.1 @@ -17581,6 +17662,11 @@ snapshots: dependencies: tmpl: 1.0.5 + mantine-form-zod-resolver@1.3.0(@mantine/form@8.1.3(react@18.3.1))(zod@3.25.56): + dependencies: + '@mantine/form': 8.1.3(react@18.3.1) + zod: 3.25.56 + markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -18196,6 +18282,10 @@ snapshots: os-tmpdir@1.0.2: {} + otpauth@9.4.0: + dependencies: + '@noble/hashes': 1.7.1 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -18443,6 +18533,8 @@ snapshots: dependencies: crc-32: 0.3.0 + pngjs@5.0.0: {} + points-on-curve@0.2.0: {} points-on-curve@1.0.1: {} @@ -18697,6 +18789,12 @@ snapshots: pwacompat@2.0.17: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.12.0: dependencies: side-channel: 1.0.6 @@ -18997,6 +19095,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -20058,6 +20158,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.3 + which-module@2.0.1: {} + which-typed-array@1.1.16: dependencies: available-typed-arrays: 1.0.7 @@ -20163,6 +20265,8 @@ snapshots: lib0: 0.2.108 yjs: 13.6.27 + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -20173,8 +20277,27 @@ snapshots: yaml@2.7.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 From b30bf61dc4ef593f9b66921b989e6ed3b49c0fbe Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:21:40 +0100 Subject: [PATCH 21/24] feat: home space list (#1400) --- .../public/locales/en-US/translation.json | 1 + apps/client/src/App.tsx | 2 + .../components/layouts/global/app-header.tsx | 4 +- .../layouts/global/global-app-shell.tsx | 6 +- .../features/space/components/space-grid.tsx | 25 ++- .../spaces-page/all-spaces-list.module.css | 10 ++ .../spaces-page/all-spaces-list.tsx | 160 ++++++++++++++++++ .../space/components/spaces-page/index.ts | 1 + apps/client/src/lib/app-route.ts | 1 + apps/client/src/pages/spaces/spaces.tsx | 53 ++++++ 10 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 apps/client/src/features/space/components/spaces-page/all-spaces-list.module.css create mode 100644 apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx create mode 100644 apps/client/src/features/space/components/spaces-page/index.ts create mode 100644 apps/client/src/pages/spaces/spaces.tsx diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 73a8c1fd..bc5146af 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -403,6 +403,7 @@ "Replace (Enter)": "Replace (Enter)", "Replace all (Ctrl+Alt+Enter)": "Replace all (Ctrl+Alt+Enter)", "Replace all": "Replace all", + "View all spaces": "View all spaces" "Error": "Error", "Failed to disable MFA": "Failed to disable MFA", "Disable two-factor authentication": "Disable two-factor authentication", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 29b4bb0e..f2772bb8 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -31,6 +31,7 @@ import Shares from "@/pages/settings/shares/shares.tsx"; import ShareLayout from "@/features/share/components/share-layout.tsx"; import ShareRedirect from "@/pages/share/share-redirect.tsx"; import { useTrackOrigin } from "@/hooks/use-track-origin"; +import SpacesPage from "@/pages/spaces/spaces.tsx"; import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; @@ -77,6 +78,7 @@ export default function App() { }> } /> + } /> } /> ( @@ -38,7 +40,7 @@ export function AppHeader() { <> - {!isHomeRoute && ( + {!hideSidebar && ( <> - {!isHomeRoute && ( + {!hideSidebar && ( ( - - {t("Spaces you belong to")} - + + + {t("Spaces you belong to")} + + {cards} + + + + ); } diff --git a/apps/client/src/features/space/components/spaces-page/all-spaces-list.module.css b/apps/client/src/features/space/components/spaces-page/all-spaces-list.module.css new file mode 100644 index 00000000..9baea232 --- /dev/null +++ b/apps/client/src/features/space/components/spaces-page/all-spaces-list.module.css @@ -0,0 +1,10 @@ +.spaceLink { + text-decoration: none; + color: inherit; + display: flex; + width: 100%; + + &:hover { + text-decoration: none; + } +} \ No newline at end of file diff --git a/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx new file mode 100644 index 00000000..deb7a0df --- /dev/null +++ b/apps/client/src/features/space/components/spaces-page/all-spaces-list.tsx @@ -0,0 +1,160 @@ +import { + Table, + Text, + Group, + ActionIcon, + Box, + Space, + Menu, + Avatar, + Anchor, +} from "@mantine/core"; +import { IconDots, IconSettings } from "@tabler/icons-react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { useDisclosure } from "@mantine/hooks"; +import { formatMemberCount } from "@/lib"; +import { getSpaceUrl } from "@/lib/config"; +import { prefetchSpace } from "@/features/space/queries/space-query"; +import { SearchInput } from "@/components/common/search-input"; +import Paginate from "@/components/common/paginate"; +import NoTableResults from "@/components/common/no-table-results"; +import SpaceSettingsModal from "@/features/space/components/settings-modal"; +import classes from "./all-spaces-list.module.css"; + +interface AllSpacesListProps { + spaces: any[]; + onSearch: (query: string) => void; + page: number; + hasPrevPage?: boolean; + hasNextPage?: boolean; + onPageChange: (page: number) => void; +} + +export default function AllSpacesList({ + spaces, + onSearch, + page, + hasPrevPage, + hasNextPage, + onPageChange, +}: AllSpacesListProps) { + const { t } = useTranslation(); + const [settingsOpened, { open: openSettings, close: closeSettings }] = + useDisclosure(false); + const [selectedSpaceId, setSelectedSpaceId] = useState(null); + + const handleOpenSettings = (spaceId: string) => { + setSelectedSpaceId(spaceId); + openSettings(); + }; + + return ( + + + + + + + + + + {t("Space")} + {t("Members")} + + + + + + {spaces.length > 0 ? ( + spaces.map((space) => ( + + + + prefetchSpace(space.slug, space.id)} + > + +
+ + {space.name} + + {space.description && ( + + {space.description} + + )} +
+
+
+
+ + + {formatMemberCount(space.memberCount, t)} + + + + + + + + + + + + } + onClick={() => handleOpenSettings(space.id)} + > + {t("Space settings")} + + + + + +
+ )) + ) : ( + + )} +
+
+
+ + {spaces.length > 0 && ( + + )} + + {selectedSpaceId && ( + + )} +
+ ); +} diff --git a/apps/client/src/features/space/components/spaces-page/index.ts b/apps/client/src/features/space/components/spaces-page/index.ts new file mode 100644 index 00000000..748b581e --- /dev/null +++ b/apps/client/src/features/space/components/spaces-page/index.ts @@ -0,0 +1 @@ +export { default as AllSpacesList } from "./all-spaces-list"; \ No newline at end of file diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index 7b95d5c4..dc42dad5 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -1,5 +1,6 @@ const APP_ROUTE = { HOME: "/home", + SPACES: "/spaces", AUTH: { LOGIN: "/login", SIGNUP: "/signup", diff --git a/apps/client/src/pages/spaces/spaces.tsx b/apps/client/src/pages/spaces/spaces.tsx new file mode 100644 index 00000000..30df05d4 --- /dev/null +++ b/apps/client/src/pages/spaces/spaces.tsx @@ -0,0 +1,53 @@ +import { Container, Title, Text, Group, Box } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Helmet } from "react-helmet-async"; +import { getAppName } from "@/lib/config"; +import { useGetSpacesQuery } from "@/features/space/queries/space-query"; +import CreateSpaceModal from "@/features/space/components/create-space-modal"; +import { AllSpacesList } from "@/features/space/components/spaces-page"; +import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search"; +import useUserRole from "@/hooks/use-user-role"; + +export default function Spaces() { + const { t } = useTranslation(); + const { isAdmin } = useUserRole(); + const { search, page, setPage, handleSearch } = usePaginateAndSearch(); + + const { data, isLoading } = useGetSpacesQuery({ + page, + limit: 30, + query: search, + }); + + return ( + <> + + + {t("Spaces")} - {getAppName()} + + + + + + {t("Spaces")} + {isAdmin && } + + + + + {t("Spaces you belong to")} + + + + + + + ); +} From 32c7ecd9cfacc4f52dad00536af669097b0cfd93 Mon Sep 17 00:00:00 2001 From: Alexander Schaber Date: Fri, 25 Jul 2025 01:22:27 +0200 Subject: [PATCH 22/24] feat: set mermaid theme based on computed color scheme (#1397) Use Mantine's `useComputedColorScheme` hook to dynamically configure mermaid's theme. - When the computed color scheme is "light", the theme is set to "default". - Otherwise, it is set to "dark". --- .../features/editor/components/code-block/mermaid-view.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/client/src/features/editor/components/code-block/mermaid-view.tsx b/apps/client/src/features/editor/components/code-block/mermaid-view.tsx index 5f2cf845..d51e5604 100644 --- a/apps/client/src/features/editor/components/code-block/mermaid-view.tsx +++ b/apps/client/src/features/editor/components/code-block/mermaid-view.tsx @@ -4,10 +4,14 @@ import mermaid from "mermaid"; import { v4 as uuidv4 } from "uuid"; import classes from "./code-block.module.css"; import { useTranslation } from "react-i18next"; +import { useComputedColorScheme } from "@mantine/core"; + +const computedColorScheme = useComputedColorScheme(); mermaid.initialize({ startOnLoad: false, suppressErrorRendering: true, + theme: computedColorScheme === "light" ? "default" : "dark", }); interface MermaidViewProps { From ad5cf1e18bcb0534eab031db01c30ed94c60d850 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Fri, 25 Jul 2025 00:23:14 +0100 Subject: [PATCH 23/24] =?UTF-8?q?feat:=20add=20resizable=20embed=20compone?= =?UTF-8?q?nt=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20=20?= =?UTF-8?q?=20=20=20=20=20=20=20=20=20=20=20=20=E2=94=82=20(#1401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created reusable ResizableWrapper component - Added drag-to-resize functionality for embeds --- .../common/resizable-wrapper.module.css | 96 +++++++++++++++ .../components/common/resizable-wrapper.tsx | 112 ++++++++++++++++++ .../components/embed/embed-view.module.css | 16 +++ .../editor/components/embed/embed-view.tsx | 44 ++++--- 4 files changed, 253 insertions(+), 15 deletions(-) create mode 100644 apps/client/src/features/editor/components/common/resizable-wrapper.module.css create mode 100644 apps/client/src/features/editor/components/common/resizable-wrapper.tsx create mode 100644 apps/client/src/features/editor/components/embed/embed-view.module.css diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css new file mode 100644 index 00000000..02791e86 --- /dev/null +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css @@ -0,0 +1,96 @@ +.wrapper { + position: relative; + width: 100%; + overflow: hidden; + border-radius: 8px; +} + +.resizing { + user-select: none; + cursor: ns-resize; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; + background: transparent; +} + +.resizeHandleBottom { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 24px; + cursor: ns-resize; + opacity: 0; + transition: opacity 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + touch-action: none; + -webkit-user-select: none; + user-select: none; + + @mixin light { + background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05)); + } + + @mixin dark { + background: linear-gradient( + to bottom, + transparent, + rgba(255, 255, 255, 0.05) + ); + } + + &:hover { + @mixin light { + background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1)); + } + + @mixin dark { + background: linear-gradient( + to bottom, + transparent, + rgba(255, 255, 255, 0.1) + ); + } + } +} + +.wrapper:hover .resizeHandleBottom, +.resizing .resizeHandleBottom { + opacity: 1; +} + +.resizeBar { + width: 50px; + height: 4px; + border-radius: 2px; + transition: background-color 0.2s ease; + + @mixin light { + background-color: var(--mantine-color-gray-5); + } + + @mixin dark { + background-color: var(--mantine-color-gray-6); + } +} + +.resizeHandleBottom:hover .resizeBar, +.resizing .resizeBar { + @mixin light { + background-color: var(--mantine-color-gray-7); + } + + @mixin dark { + background-color: var(--mantine-color-gray-4); + } +} diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx new file mode 100644 index 00000000..c3cd1b62 --- /dev/null +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx @@ -0,0 +1,112 @@ +import React, { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import classes from "./resizable-wrapper.module.css"; + +interface ResizableWrapperProps { + children: ReactNode; + initialHeight?: number; + minHeight?: number; + maxHeight?: number; + onResize?: (height: number) => void; + isEditable?: boolean; + className?: string; + showHandles?: "always" | "hover"; + direction?: "vertical" | "horizontal" | "both"; +} + +export const ResizableWrapper: React.FC = ({ + children, + initialHeight = 480, + minHeight = 200, + maxHeight = 1200, + onResize, + isEditable = true, + className, + showHandles = "hover", + direction = "vertical", +}) => { + const [resizeParams, setResizeParams] = useState<{ + initialSize: number; + initialClientY: number; + initialClientX: number; + } | null>(null); + const [currentHeight, setCurrentHeight] = useState(initialHeight); + const [isHovered, setIsHovered] = useState(false); + const wrapperRef = useRef(null); + + useEffect(() => { + if (!resizeParams) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!wrapperRef.current) return; + + if (direction === "vertical" || direction === "both") { + const deltaY = e.clientY - resizeParams.initialClientY; + const newHeight = Math.min( + Math.max(resizeParams.initialSize + deltaY, minHeight), + maxHeight + ); + setCurrentHeight(newHeight); + wrapperRef.current.style.height = `${newHeight}px`; + } + }; + + const handleMouseUp = () => { + setResizeParams(null); + if (onResize && currentHeight !== initialHeight) { + onResize(currentHeight); + } + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]); + + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setResizeParams({ + initialSize: currentHeight, + initialClientY: e.clientY, + initialClientX: e.clientX, + }); + + document.body.style.cursor = "ns-resize"; + document.body.style.userSelect = "none"; + }, [currentHeight]); + + const shouldShowHandles = + isEditable && + (showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams))); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {children} + {!!resizeParams &&
} + {shouldShowHandles && direction === "vertical" && ( +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/client/src/features/editor/components/embed/embed-view.module.css b/apps/client/src/features/editor/components/embed/embed-view.module.css new file mode 100644 index 00000000..c58f3965 --- /dev/null +++ b/apps/client/src/features/editor/components/embed/embed-view.module.css @@ -0,0 +1,16 @@ +.embedWrapper { + @mixin light { + background-color: var(--mantine-color-gray-0); + } + + @mixin dark { + background-color: var(--mantine-color-dark-7); + } +} + +.embedIframe { + width: 100%; + height: 100%; + border: none; + border-radius: 8px; +} \ No newline at end of file diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index dfc6a5da..414ccdaf 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -1,9 +1,8 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { useMemo } from "react"; +import React, { useMemo, useCallback } from "react"; import clsx from "clsx"; import { ActionIcon, - AspectRatio, Button, Card, FocusTrap, @@ -14,7 +13,8 @@ import { } from "@mantine/core"; import { IconEdit } from "@tabler/icons-react"; import { z } from "zod"; -import { useForm, zodResolver } from "@mantine/form"; +import { useForm } from "@mantine/form"; +import { zodResolver } from "mantine-form-zod-resolver"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; import i18n from "i18next"; @@ -22,6 +22,8 @@ import { getEmbedProviderById, getEmbedUrlAndProvider, } from "@docmost/editor-ext"; +import { ResizableWrapper } from "../common/resizable-wrapper"; +import classes from "./embed-view.module.css"; const schema = z.object({ url: z @@ -33,7 +35,7 @@ const schema = z.object({ export default function EmbedView(props: NodeViewProps) { const { t } = useTranslation(); const { node, selected, updateAttributes, editor } = props; - const { src, provider } = node.attrs; + const { src, provider, height: nodeHeight } = node.attrs; const embedUrl = useMemo(() => { if (src) { @@ -49,6 +51,10 @@ export default function EmbedView(props: NodeViewProps) { validate: zodResolver(schema), }); + const handleResize = useCallback((newHeight: number) => { + updateAttributes({ height: newHeight }); + }, [updateAttributes]); + async function onSubmit(data: { url: string }) { if (!editor.isEditable) { return; @@ -77,17 +83,25 @@ export default function EmbedView(props: NodeViewProps) { return ( {embedUrl ? ( - <> - - - - + +