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..e426e0ba 100644
--- a/apps/client/src/features/editor/styles/index.css
+++ b/apps/client/src/features/editor/styles/index.css
@@ -9,5 +9,6 @@
@import "./media.css";
@import "./code.css";
@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/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/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/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/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/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 db818518..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;
@@ -90,8 +97,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 +212,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 +231,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}
@@ -383,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 [
@@ -461,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 (
<>
@@ -505,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
{!(treeApi.props.disableEdit as boolean) && (
<>
+ }
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleDuplicatePage();
+ }}
+ >
+ {t("Duplicate")}
+
+
}
onClick={(e) => {
@@ -524,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/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/client/src/features/space/components/space-grid.tsx b/apps/client/src/features/space/components/space-grid.tsx
index 4680a336..6f16582e 100644
--- a/apps/client/src/features/space/components/space-grid.tsx
+++ b/apps/client/src/features/space/components/space-grid.tsx
@@ -1,4 +1,4 @@
-import { Text, Avatar, SimpleGrid, Card, rem } from "@mantine/core";
+import { Text, Avatar, SimpleGrid, Card, rem, Group, Button } from "@mantine/core";
import React, { useEffect } from 'react';
import {
prefetchSpace,
@@ -9,10 +9,11 @@ import { Link } from "react-router-dom";
import classes from "./space-grid.module.css";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
+import { IconArrowRight } from "@tabler/icons-react";
export default function SpaceGrid() {
const { t } = useTranslation();
- const { data, isLoading } = useGetSpacesQuery({ page: 1 });
+ const { data, isLoading } = useGetSpacesQuery({ page: 1, limit: 9 });
const cards = data?.items.map((space, index) => (
-
- {t("Spaces you belong to")}
-
+
+
+ {t("Spaces you belong to")}
+
+
{cards}
+
+
+ }
+ size="sm"
+ >
+ {t("View all spaces")}
+
+
>
);
}
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/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() {
{/*
-
+
{t("Change email")}
*/}
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.")}
-
+
{t("Change password")}
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..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",
@@ -8,6 +9,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/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")}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/server/package.json b/apps/server/package.json
index 63fa0be1..1240e5cc 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -71,6 +71,8 @@
"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",
"pg": "^8.16.0",
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index db2771b2..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,9 +46,11 @@ export const tiptapExtensions = [
codeBlock: false,
}),
Comment,
- TextAlign.configure({ types: ["heading", "paragraph"] }),
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList,
- TaskItem,
+ TaskItem.configure({
+ nested: true,
+ }),
Underline,
LinkExtension,
Superscript,
@@ -64,9 +66,9 @@ export const tiptapExtensions = [
DetailsContent,
DetailsSummary,
Table,
- TableHeader,
- TableRow,
TableCell,
+ TableRow,
+ TableHeader,
Youtube,
TiptapImage,
TiptapVideo,
@@ -76,7 +78,7 @@ export const tiptapExtensions = [
Drawio,
Excalidraw,
Embed,
- Mention
+ Mention,
] as any;
export function jsonToHtml(tiptapJson: any) {
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/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 fb98ed7f..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)
@@ -108,7 +163,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/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 9c761ef3..72ffc521 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';
@@ -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(
@@ -222,9 +238,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..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';
@@ -22,7 +23,7 @@ export class TokenService {
) {}
async generateAccessToken(user: User): Promise {
- if (user.deletedAt) {
+ if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException();
}
@@ -35,12 +36,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,
};
@@ -75,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 083444f2..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(),
@@ -42,15 +43,10 @@ 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();
}
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/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 f8caeb55..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')
@@ -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();
@@ -239,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) {
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)
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/migrations/20250630T012831-add-resolved_by_id-to-comments.ts b/apps/server/src/database/migrations/20250723T012831-add-resolved_by_id-to-comments.ts
similarity index 100%
rename from apps/server/src/database/migrations/20250630T012831-add-resolved_by_id-to-comments.ts
rename to apps/server/src/database/migrations/20250723T012831-add-resolved_by_id-to-comments.ts
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 b9e27195..ab1394c0 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -248,6 +248,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;
@@ -301,6 +313,7 @@ export interface Workspaces {
deletedAt: Timestamp | null;
description: string | null;
emailDomains: Generated;
+ enforceMfa: Generated;
enforceSso: Generated;
hostname: string | null;
id: Generated;
@@ -330,6 +343,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 59f5eac5..4d63297d 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 59f5eac5072030ebb806f10d5ac330297d4ed5e0
+Subproject commit 4d63297df08a5c74dc8565f769dc3b83486686d2
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/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/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/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;
+ },
};
},
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;
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";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b8c0c88c..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
@@ -222,23 +228,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)
@@ -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,12 @@ 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
passport-google-oauth20:
specifier: ^2.0.0
version: 2.0.0
@@ -2503,49 +2518,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
@@ -2843,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'}
@@ -4342,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==}
@@ -5089,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'}
@@ -5515,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==}
@@ -5614,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==}
@@ -7158,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
@@ -7629,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'}
@@ -7637,6 +7679,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'}
@@ -7871,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==}
@@ -8111,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'}
@@ -8265,8 +8320,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
@@ -8380,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'}
@@ -9385,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'}
@@ -9524,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'}
@@ -9543,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'}
@@ -9567,6 +9639,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'}
@@ -12228,55 +12304,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
@@ -12528,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
@@ -14158,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': {}
@@ -15113,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
@@ -15555,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:
@@ -15633,6 +15723,8 @@ snapshots:
diff@5.2.0: {}
+ dijkstrajs@1.0.3: {}
+
dnd-core@14.0.1:
dependencies:
'@react-dnd/asap': 4.0.1
@@ -17570,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
@@ -18185,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
@@ -18193,6 +18294,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
@@ -18428,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: {}
@@ -18682,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
@@ -18849,7 +18962,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
@@ -18982,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
@@ -20043,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
@@ -20148,6 +20265,8 @@ snapshots:
lib0: 0.2.108
yjs: 13.6.27
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -20158,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
@@ -20183,6 +20321,8 @@ snapshots:
yocto-queue@0.1.0: {}
+ yocto-queue@1.2.1: {}
+
yoctocolors-cjs@2.1.2: {}
zeed-dom@0.15.1: