Compare commits

..
Author SHA1 Message Date
Philipinho 0501b334b5 feat(ee): space control to hide comments from viewers role 2026-08-13 01:28:02 +01:00
Philip Okugbe 7439da2f6e chore: package updates (#2383)
* update

* update versions

* package updates
2026-08-10 17:56:12 +01:00
Philip Okugbe 305fd40686 fix: browser tab showing url instead of page title (#2382) 2026-08-10 17:38:03 +01:00
Philipinho 4bd51d7404 fix: self-reference parent page 2026-08-10 17:37:12 +01:00
Philip Okugbe 89378ee766 chore: package updates (#2378)
* chore: package updates

* remove stale overrides
2026-08-06 23:41:36 +01:00
Philip Okugbe 56ac42767a fix: stale placeholder decorations on empty paragraphs (#2374) 2026-08-02 21:07:50 +01:00
61 changed files with 2333 additions and 1671 deletions
+4 -4
View File
@@ -50,9 +50,9 @@
"katex": "0.16.40",
"lowlight": "3.3.0",
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.15.0",
"mermaid": "11.16.1",
"mitt": "3.0.1",
"nanoid": "3.3.8",
"nanoid": "3.3.17",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
@@ -61,7 +61,7 @@
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
"react-router-dom": "7.18.0",
"react-router-dom": "7.18.2",
"semver": "7.7.4",
"socket.io-client": "4.8.3",
"zod": "4.3.6"
@@ -86,7 +86,7 @@
"globals": "15.13.0",
"jsdom": "25.0.0",
"optics-ts": "2.4.1",
"postcss": "8.5.14",
"postcss": "8.5.25",
"postcss-preset-mantine": "1.18.0",
"postcss-simple-vars": "7.0.1",
"prettier": "3.8.1",
@@ -508,6 +508,11 @@
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Hide comments from viewers": "Hide comments from viewers",
"Viewers cannot see or add comments on pages in this space.": "Viewers cannot see or add comments on pages in this space.",
"Toggle hide comments from viewers": "Toggle hide comments from viewers",
"Turn off 'Allow viewers to comment' first": "Turn off 'Allow viewers to comment' first",
"Turn off 'Hide comments from viewers' first": "Turn off 'Hide comments from viewers' first",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions",
@@ -11,11 +11,13 @@ import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
export default function Aside() {
const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const canViewComments = useCanViewComments();
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
useEffect(() => {
@@ -23,12 +25,18 @@ export default function Aside() {
document.getElementById(ASIDE_PANEL_ID)?.focus();
}, [isAsideOpen, tab]);
useEffect(() => {
if (isAsideOpen && tab === "comments" && !canViewComments) {
setAsideState({ tab: "", isAsideOpen: false });
}
}, [isAsideOpen, tab, canViewComments, setAsideState]);
let title: string;
let component: ReactNode;
switch (tab) {
case "comments":
component = <CommentListWithTabs />;
component = canViewComments ? <CommentListWithTabs /> : null;
title = "Comments";
break;
case "toc":
@@ -0,0 +1,45 @@
import { describe, expect, it, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { HelmetProvider } from "react-helmet-async";
import { DocumentTitle } from "./document-title.tsx";
const renderTitle = (ui: React.ReactNode) =>
render(<HelmetProvider>{ui}</HelmetProvider>);
describe("DocumentTitle", () => {
beforeEach(() => {
document.head.innerHTML = "<title>Docmost</title>";
});
it("appends the app name", () => {
renderTitle(<DocumentTitle title="Home" />);
expect(document.title).toBe("Home - Docmost");
});
it("omits the app name when asked", () => {
renderTitle(<DocumentTitle title="My page" withAppName={false} />);
expect(document.title).toBe("My page");
});
it("falls back to the app name without a title", () => {
renderTitle(<DocumentTitle />);
expect(document.title).toBe("Docmost");
});
it("never renders an empty title", () => {
renderTitle(<DocumentTitle title="Spaces" />);
const titles = Array.from(document.querySelectorAll("head > title"));
expect(titles.every((node) => node.textContent !== "")).toBe(true);
});
it("renders extra head children", () => {
renderTitle(
<DocumentTitle title="Shared">
<meta name="robots" content="noindex" />
</DocumentTitle>,
);
expect(
document.querySelector('head > meta[name="robots"]')?.getAttribute("content"),
).toBe("noindex");
});
});
@@ -0,0 +1,29 @@
import React from "react";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
type DocumentTitleProps = {
title?: string;
withAppName?: boolean;
children?: React.ReactNode;
};
export function DocumentTitle({
title,
withAppName = true,
children,
}: DocumentTitleProps) {
const appName = getAppName();
let documentTitle = appName;
if (title) {
documentTitle = withAppName ? `${title} - ${appName}` : title;
}
return (
<Helmet>
<title>{documentTitle}</title>
{children}
</Helmet>
);
}
+2 -4
View File
@@ -1,17 +1,15 @@
import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export function Error404() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("404 page not found")} - Docmost</title>
</Helmet>
<DocumentTitle title={t("404 page not found")} />
<Container className={classes.root}>
<Title className={classes.title}>{t("404 page not found")}</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -15,6 +13,7 @@ import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AiSettings() {
const { t } = useTranslation();
@@ -40,9 +39,7 @@ export default function AiSettings() {
return (
<>
<Helmet>
<title>AI settings - {getAppName()}</title>
</Helmet>
<DocumentTitle title="AI settings" />
<SettingsTitle title={t("AI settings")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
@@ -1,10 +1,9 @@
import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config";
import { getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -17,6 +16,7 @@ import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -49,11 +49,7 @@ export default function UserApiKeys() {
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("API keys")} />
<SettingsTitle title={t("API keys")} />
@@ -1,9 +1,7 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
@@ -15,6 +13,7 @@ import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
@@ -47,11 +46,7 @@ export default function WorkspaceApiKeys() {
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("API management")} />
<SettingsTitle title={t("API management")} />
@@ -10,11 +10,9 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
@@ -26,6 +24,7 @@ import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
type RetentionUnit = "days" | "months" | "years";
@@ -97,11 +96,7 @@ export default function AuditLogs() {
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Audit log")} />
<SettingsTitle title={t("Audit log")} />
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
@@ -9,6 +7,7 @@ import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
@@ -20,9 +19,7 @@ export default function Billing() {
return (
<>
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Billing" />
<SettingsTitle title="Billing" />
<BillingTrial />
+1
View File
@@ -19,6 +19,7 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
+2 -5
View File
@@ -1,5 +1,3 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
@@ -9,6 +7,7 @@ import InstallationDetails from "@/ee/licence/components/installation-details.ts
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function License() {
const [entitlements] = useAtom(entitlementAtom);
@@ -21,9 +20,7 @@ export default function License() {
return (
<>
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<DocumentTitle title="License" />
<SettingsTitle title="License" />
<ActivateLicenseForm />
@@ -1,17 +1,16 @@
import { useState, useMemo } from "react";
import { Group, MultiSelect, Select, Space, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSearch } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useVerificationListQuery } from "@/ee/page-verification/queries/page-verification-query";
import { IVerificationListParams } from "@/ee/page-verification/types/page-verification.types";
import VerificationListTable from "@/ee/page-verification/components/verification-list-table";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function VerifiedPages() {
const { t } = useTranslation();
@@ -68,11 +67,7 @@ export default function VerifiedPages() {
return (
<>
<Helmet>
<title>
{t("Verified pages")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Verified pages")} />
<SettingsTitle title={t("Verified pages")} />
+2 -7
View File
@@ -1,18 +1,13 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CloudLogin() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Login")} />
<CloudLoginForm />
</>
@@ -1,14 +1,11 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react";
import { getAppName } from "@/lib/config.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function CreateWorkspace() {
return (
<>
<Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Create Workspace" />
<SetupWorkspaceForm />
</>
);
@@ -0,0 +1,62 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceHideCommentsToggleProps = {
space: ISpace;
};
export default function SpaceHideCommentsToggle({
space,
}: SpaceHideCommentsToggleProps) {
const { t } = useTranslation();
const hasHideComments = useHasFeature(Feature.HIDE_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const allowViewerCommentsEnabled =
space.settings?.comments?.allowViewerComments === true;
const isDisabled = !hasHideComments || allowViewerCommentsEnabled;
const tooltipLabel = !hasHideComments
? upgradeLabel
: t("Turn off 'Allow viewers to comment' first");
const [checked, setChecked] = useState(
space.settings?.comments?.hideCommentsFromViewers === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
hideCommentsFromViewers: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Hide comments from viewers")}</Text>
<Text size="sm" c="dimmed">
{t("Viewers cannot see or add comments on pages in this space.")}
</Text>
</div>
<Tooltip label={tooltipLabel} disabled={!isDisabled} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle hide comments from viewers")}
/>
</Tooltip>
</Group>
);
}
@@ -17,7 +17,12 @@ export default function SpaceViewerCommentsToggle({
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const hideCommentsEnabled =
space.settings?.comments?.hideCommentsFromViewers === true;
const isDisabled = !hasViewerComments || hideCommentsEnabled;
const tooltipLabel = !hasViewerComments
? upgradeLabel
: t("Turn off 'Hide comments from viewers' first");
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
@@ -45,7 +50,7 @@ export default function SpaceViewerCommentsToggle({
</Text>
</div>
<Tooltip
label={upgradeLabel}
label={tooltipLabel}
disabled={!isDisabled}
refProp="rootRef"
>
@@ -1,5 +1,4 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import { isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import {
Alert,
@@ -37,6 +36,7 @@ import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const SCIM_TOKEN_LIMIT = 5;
@@ -64,9 +64,7 @@ export default function Security() {
return (
<>
<Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Security" />
<SettingsTitle title={t("Security")} />
<EnforceMfa />
@@ -22,8 +22,6 @@ import { useTranslation } from "react-i18next";
import { useDisclosure, useWindowEvent } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import { Link, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config";
import { useEditor, EditorContent } from "@tiptap/react";
import { templateExtensions } from "@/features/editor/extensions/extensions";
import {
@@ -44,6 +42,7 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateEditor() {
const { t } = useTranslation();
@@ -247,11 +246,7 @@ export default function TemplateEditor() {
return (
<>
<Helmet>
<title>
{t("Edit template")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Edit template")} />
{editorToolbarEnabled && editor && (
<FixedToolbar editor={editor} templateMode />
@@ -13,11 +13,9 @@ import {
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconPlus } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useDisclosure } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import {
useGetTemplatesQuery,
useDeleteTemplateMutation,
@@ -31,6 +29,7 @@ import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function TemplateList() {
const { t } = useTranslation();
@@ -102,11 +101,7 @@ export default function TemplateList() {
return (
<>
<Helmet>
<title>
{t("Templates")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Templates")} />
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
@@ -0,0 +1,15 @@
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { extractPageSlugId } from "@/lib";
export function useCanViewComments(): boolean {
const { pageSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
return (
canEdit || space?.settings?.comments?.hideCommentsFromViewers !== true
);
}
+27 -13
View File
@@ -82,6 +82,8 @@ import {
getCollabSocket,
releaseCollabSocket,
} from "@/features/editor/collab-socket";
import clsx from "clsx";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageEditorProps {
pageId: string;
@@ -196,6 +198,7 @@ function CollabPageEditor({
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canViewComments = useCanViewComments();
const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted],
@@ -372,6 +375,7 @@ function CollabPageEditor({
};
useEffect(() => {
if (!canViewComments) return;
document.addEventListener("ACTIVE_COMMENT_EVENT", handleActiveCommentEvent);
return () => {
document.removeEventListener(
@@ -379,7 +383,7 @@ function CollabPageEditor({
handleActiveCommentEvent,
);
};
}, []);
}, [canViewComments]);
useEffect(() => {
setActiveCommentId(null);
@@ -430,7 +434,13 @@ function CollabPageEditor({
}
return (
<div className="editor-container" style={{ position: "relative" }}>
<div
className={clsx(
"editor-container",
!canViewComments && "comments-hidden",
)}
style={{ position: "relative" }}
>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
@@ -480,17 +490,21 @@ function StaticPageEditor({
content: any;
ariaLabel: string;
}) {
const canViewComments = useCanViewComments();
return (
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
<div className={clsx(!canViewComments && "comments-hidden")}>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": ariaLabel,
},
}}
/>
</div>
);
}
@@ -315,3 +315,8 @@
height: 100%;
}
}
.comments-hidden .ProseMirror .comment-mark {
background: none;
border-bottom: none;
}
@@ -58,6 +58,7 @@ import {
useWatchPageMutation,
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
import { useCanViewComments } from "@/features/comment/hooks/use-can-view-comments.ts";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -65,6 +66,7 @@ interface PageHeaderMenuProps {
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
const { t } = useTranslation();
const commentsTriggerProps = useAsideTriggerProps("comments");
const canViewComments = useCanViewComments();
const tocTriggerProps = useAsideTriggerProps("toc");
const { pageSlug } = useParams();
const { data: page } = usePageQuery({
@@ -105,16 +107,18 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<PageShareModal readOnly={readOnly} />
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
{canViewComments && (
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Comments")}
{...commentsTriggerProps}
>
<IconMessage size={20} stroke={2} />
</ActionIcon>
</Tooltip>
)}
{!page?.isBase && (
<Tooltip label={t("Table of contents")} openDelay={250} withArrow>
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
import SpaceHideCommentsToggle from "@/ee/security/components/space-hide-comments-toggle.tsx";
type SpaceSecuritySettingsProps = {
space: ISpace;
@@ -29,6 +30,10 @@ export default function SpaceSecuritySettings({
<Divider my="lg" />
<SpaceViewerCommentsToggle space={space} />
<Divider my="lg" />
<SpaceHideCommentsToggle space={space} />
</div>
);
}
@@ -11,6 +11,7 @@ export interface ISpaceSharingSettings {
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
export interface ISpaceSettings {
@@ -36,6 +37,7 @@ export interface ISpace {
// for updates
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
hideCommentsFromViewers?: boolean;
}
interface IMembership {
@@ -1,13 +1,10 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { getAppName } from "@/lib/config";
import { Helmet } from "react-helmet-async";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function ForgotPassword() {
return (
<>
<Helmet>
<title>Forgot Password - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Forgot Password" />
<ForgotPasswordForm />
</>
);
+2 -5
View File
@@ -1,16 +1,13 @@
import { Helmet } from "react-helmet-async";
import { InviteSignUpForm } from "@/features/auth/components/invite-sign-up-form.tsx";
import {getAppName} from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function InviteSignup() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("Invitation Signup")} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={t("Invitation Signup")} />
<InviteSignUpForm />
</>
);
+2 -7
View File
@@ -1,18 +1,13 @@
import { LoginForm } from "@/features/auth/components/login-form";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LoginPage() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Login")} />
<LoginForm />
</>
);
+3 -12
View File
@@ -1,11 +1,10 @@
import { Helmet } from "react-helmet-async";
import { PasswordResetForm } from "@/features/auth/components/password-reset-form";
import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function PasswordReset() {
const { t } = useTranslation();
@@ -23,11 +22,7 @@ export default function PasswordReset() {
if (isError || !resetToken) {
return (
<>
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Password Reset")} />
<Container my={40}>
<Text size="lg" ta="center">
{t("Invalid or expired password reset link")}
@@ -49,11 +44,7 @@ export default function PasswordReset() {
return (
<>
<Helmet>
<title>
{t("Password Reset")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Password Reset")} />
<PasswordResetForm resetToken={resetToken} />
</>
);
@@ -1,11 +1,10 @@
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SetupWorkspace() {
const { t } = useTranslation();
@@ -35,11 +34,7 @@ export default function SetupWorkspace() {
) {
return (
<>
<Helmet>
<title>
{t("Setup Workspace")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Setup Workspace")} />
<SetupWorkspaceForm />
</>
);
+2 -7
View File
@@ -2,20 +2,15 @@ import { Container, Space } from "@mantine/core";
import HomeTabs from "@/features/home/components/home-tabs";
import HomeAiPrompt from "@/features/home/components/home-ai-prompt";
import SpaceCarousel from "@/features/space/components/space-carousel.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Home() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Home")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Home")} />
<Container size={"900"} pt="xl">
<HomeAiPrompt />
+2 -7
View File
@@ -17,9 +17,7 @@ import {
} from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Helmet } from "react-helmet-async";
import { useDebouncedValue } from "@mantine/hooks";
import { getAppName } from "@/lib/config";
import { useLabelPagesQuery } from "@/features/label/queries/label-query.ts";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { getLabelColor } from "@/features/label/utils/label-colors.ts";
@@ -29,6 +27,7 @@ import { normalizeLabelName } from "@/features/label/utils/normalize-label.ts";
import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu.tsx";
import { EmptyState } from "@/components/ui/empty-state";
import classes from "@/features/label/label.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function LabelPage() {
const { t } = useTranslation();
@@ -82,11 +81,7 @@ export default function LabelPage() {
return (
<>
<Helmet>
<title>
{labelName} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={labelName} />
<Container size={820} py="xl">
<Stack gap="lg">
+9 -7
View File
@@ -3,7 +3,6 @@ import { usePageQuery } from "@/features/page/queries/page-query";
import { FullEditor } from "@/features/editor/full-editor";
import { TitleEditor } from "@/features/editor/title-editor";
import HistoryModal from "@/features/page-history/components/history-modal";
import { Helmet } from "react-helmet-async";
import PageHeader from "@/features/page/components/header/page-header.tsx";
import { extractPageSlugId } from "@/lib";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -18,6 +17,7 @@ import { BaseView } from "@/ee/base/components/base-view";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { getPageTitle } from "@/features/page/page.utils";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
const MemoizedFullEditor = React.memo(FullEditor);
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageHeader = React.memo(PageHeader);
@@ -110,9 +110,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
paddingTop: "calc(var(--page-header-height) + 6px)",
}}
>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<MemoizedPageHeader readOnly={!canEdit} />
<div
style={{
@@ -159,9 +160,10 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
return (
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}</title>
</Helmet>
<DocumentTitle
title={`${page?.icon || ""} ${getPageTitle(page?.title, page?.isBase, t)}`}
withAppName={false}
/>
<MemoizedPageHeader readOnly={!canEdit} />
@@ -5,21 +5,16 @@ import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import FixedToolbarPref from "@/features/user/components/fixed-toolbar-pref";
import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountPreferences() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Preferences")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Preferences")} />
<SettingsTitle title={t("Preferences")} />
<AccountTheme />
@@ -4,22 +4,17 @@ 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 { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
import SessionList from "@/features/session/components/session-list";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function AccountSettings() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("My Profile")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("My Profile")} />
<SettingsTitle title={t("My Profile")} />
<AccountAvatar />
@@ -1,20 +1,15 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import GroupMembersList from "@/features/group/components/group-members";
import GroupDetails from "@/features/group/components/group-details";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function GroupInfo() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Manage Group")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Manage Group")} />
<SettingsTitle title={t("Manage Group")} />
<GroupDetails />
<GroupMembersList />
@@ -3,9 +3,8 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Group } from "@mantine/core";
import CreateGroupModal from "@/features/group/components/create-group-modal";
import useUserRole from "@/hooks/use-user-role.tsx";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Groups() {
const { t } = useTranslation();
@@ -13,9 +12,7 @@ export default function Groups() {
return (
<>
<Helmet>
<title>{t("Groups")} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={t("Groups")} />
<SettingsTitle title={t("Groups")} />
<Group my="md" justify="flex-end">
@@ -1,22 +1,17 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import ShareList from "@/features/share/components/share-list.tsx";
import { Alert, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React from "react";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Shares() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Public sharing")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Public sharing")} />
<SettingsTitle title={t("Public sharing")} />
<Alert variant="light" color="blue" icon={<IconInfoCircle />}>
@@ -3,9 +3,8 @@ import SpaceList from "@/features/space/components/space-list.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { Group } from "@mantine/core";
import CreateSpaceModal from "@/features/space/components/create-space-modal.tsx";
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() {
const { t } = useTranslation();
@@ -13,11 +12,7 @@ export default function Spaces() {
return (
<>
<Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Spaces")} />
<SettingsTitle title={t("Spaces")} />
<Group my="md" justify="flex-end">
@@ -6,11 +6,10 @@ import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import WorkspaceInvitesTable from "@/features/workspace/components/members/components/workspace-invites-table.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceMembers() {
const { t } = useTranslation();
@@ -38,11 +37,7 @@ export default function WorkspaceMembers() {
return (
<>
<Helmet>
<title>
{t("Members")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Members")} />
<SettingsTitle title={t("Members")} />
{/* <WorkspaceInviteSection /> */}
@@ -2,21 +2,19 @@ import SettingsTitle from "@/components/settings/settings-title.tsx";
import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form";
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import { useTranslation } from "react-i18next";
import { getAppName, isCloud } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { isCloud } from "@/lib/config.ts";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx";
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function WorkspaceSettings() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>Workspace Settings - {getAppName()}</title>
</Helmet>
<DocumentTitle title="Workspace Settings" />
<SettingsTitle title={t("General")} />
<WorkspaceIcon />
<WorkspaceNameForm />
+6 -4
View File
@@ -1,5 +1,4 @@
import { useNavigate, useParams } from "react-router-dom";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { Container } from "@mantine/core";
@@ -14,6 +13,7 @@ import {
sharedTreeDataAtom,
} from "@/features/share/atoms/shared-page-atom.ts";
import { isPageInTree } from "@/features/share/utils.ts";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SharedPage() {
const { t } = useTranslation();
@@ -56,12 +56,14 @@ export default function SharedPage() {
return (
<div>
<Helmet>
<title>{`${data?.page?.title || t("untitled")}`}</title>
<DocumentTitle
title={data?.page?.title || t("untitled")}
withAppName={false}
>
{!data?.share.searchIndexing && (
<meta name="robots" content="noindex" />
)}
</Helmet>
</DocumentTitle>
<Container fluid={fullWidth} size={fullWidth ? undefined : 900} p={0}>
<ReadonlyPageEditor
+2 -5
View File
@@ -2,8 +2,7 @@ import {Container} from "@mantine/core";
import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx";
import {useParams} from "react-router-dom";
import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts";
import {getAppName} from "@/lib/config.ts";
import {Helmet} from "react-helmet-async";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function SpaceHome() {
const {spaceSlug} = useParams();
@@ -11,9 +10,7 @@ export default function SpaceHome() {
return (
<>
<Helmet>
<title>{space?.name || 'Overview'} - {getAppName()}</title>
</Helmet>
<DocumentTitle title={space?.name || 'Overview'} />
<Container size={"900"} pt="xl">
{space && <SpaceHomeTabs/>}
</Container>
+2 -7
View File
@@ -1,13 +1,12 @@
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 FavoriteSpacesGrid from "@/features/space/components/spaces-page/favorite-spaces-grid";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
export default function Spaces() {
const { t } = useTranslation();
@@ -22,11 +21,7 @@ export default function Spaces() {
return (
<>
<Helmet>
<title>
{t("Spaces")} - {getAppName()}
</title>
</Helmet>
<DocumentTitle title={t("Spaces")} />
<Container size={"800"} pt="xl">
<Group justify="space-between" mb="xl">
+14 -14
View File
@@ -40,30 +40,30 @@
"@clickhouse/client": "1.18.2",
"@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.2",
"@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.29.0",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs-labs/nestjs-ioredis": "11.0.4",
"@nestjs/bullmq": "11.0.4",
"@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.1.27",
"@nestjs/common": "11.1.28",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.27",
"@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "2.1.1",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.1.27",
"@nestjs/platform-socket.io": "11.1.27",
"@nestjs/platform-fastify": "11.1.28",
"@nestjs/platform-socket.io": "11.1.28",
"@nestjs/schedule": "6.1.3",
"@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "11.1.27",
"@nestjs/websockets": "11.1.28",
"@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0",
"ai": "6.0.134",
@@ -90,8 +90,8 @@
"ldapts": "8.1.7",
"mammoth": "1.12.0",
"mime-types": "3.0.2",
"msgpackr": "^1.11.9",
"nanoid": "5.1.7",
"msgpackr": "1.11.9",
"nanoid": "5.1.16",
"nestjs-cls": "6.2.0",
"nestjs-kysely": "3.1.2",
"nestjs-pino": "4.6.1",
@@ -102,7 +102,7 @@
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.1",
"pg-tsquery": "8.4.2",
"pgvector": "^0.2.1",
"pgvector": "0.2.1",
"pino-http": "11.0.0",
"pino-pretty": "13.1.3",
"postgres": "3.4.8",
@@ -118,7 +118,7 @@
"tlds": "1.261.0",
"tmp-promise": "3.0.3",
"typesense": "3.0.5",
"undici": "7.28.0",
"undici": "7.29.0",
"ws": "8.21.0",
"yauzl": "3.4.0",
"zod": "4.3.6"
+1
View File
@@ -18,6 +18,7 @@ export const Feature = {
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
HIDE_COMMENTS: 'comment:hide',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
PERSONAL_SPACES: 'spaces:personal',
@@ -89,20 +89,29 @@ export class CommentController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(input.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return this.commentService.findByPageId(page.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('info')
async findOne(@Body() input: CommentIdDto, @AuthUser() user: User) {
async findOne(
@Body() input: CommentIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const comment = await this.commentRepo.findById(input.commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -113,7 +122,11 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanView(page, user);
await this.pageAccessService.validateCanViewComments(
page,
user,
workspace.id,
);
return comment;
}
@@ -14,6 +14,7 @@ import { CommentMentionEmail } from '@docmost/transactional/emails/comment-menti
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
import { getPageTitle } from '../../../common/helpers';
import { PageAccessService } from '../../page/page-access/page-access.service';
@Injectable()
export class CommentNotificationService {
@@ -25,6 +26,7 @@ export class CommentNotificationService {
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
private readonly pageAccessService: PageAccessService,
) {}
async processComment(data: ICommentNotificationJob, appUrl: string) {
@@ -48,7 +50,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const notifiedUserIds = new Set<string>();
notifiedUserIds.add(actorId);
@@ -72,7 +74,16 @@ export class CommentNotificationService {
pageId,
[...usersWithSpaceAccess],
);
const usersWithAccess = new Set(usersWithPageAccess);
let accessibleUserIds = usersWithPageAccess;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
accessibleUserIds =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
accessibleUserIds,
);
}
const usersWithAccess = new Set(accessibleUserIds);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
@@ -145,7 +156,7 @@ export class CommentNotificationService {
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const { actor, pageTitle, pageUrl, spaceSettings } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
@@ -166,6 +177,16 @@ export class CommentNotificationService {
);
if (hasPageAccess.length === 0) return;
if (spaceSettings?.comments?.hideCommentsFromViewers === true) {
const editCapable =
await this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
[commentCreatorId],
);
if (editCapable.length === 0) return;
}
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
@@ -225,7 +246,7 @@ export class CommentNotificationService {
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.select(['id', 'slug', 'settings'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
@@ -236,6 +257,11 @@ export class CommentNotificationService {
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
return {
actor,
pageTitle: getPageTitle(page.title),
pageUrl,
spaceSettings: (space.settings ?? null) as Record<string, any> | null,
};
}
}
@@ -7,6 +7,7 @@ import {
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@Injectable()
export class PageAccessService {
@@ -14,6 +15,7 @@ export class PageAccessService {
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
/**
@@ -118,8 +120,68 @@ export class PageAccessService {
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (!settings?.comments?.allowViewerComments) {
if (
!settings?.comments?.allowViewerComments ||
settings?.comments?.hideCommentsFromViewers
) {
throw new ForbiddenException();
}
}
async validateCanViewComments(
page: Page,
user: User,
workspaceId: string,
): Promise<void> {
const { canEdit } = await this.validateCanViewWithPermissions(page, user);
if (canEdit) {
return;
}
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (settings?.comments?.hideCommentsFromViewers) {
throw new ForbiddenException();
}
}
/**
* Callers must pass userIds that already have space access (WS room members / pre-filtered notification recipients).
*/
async filterUserIdsWithPageEditAccess(
spaceId: string,
pageId: string,
userIds: string[],
): Promise<string[]> {
if (userIds.length === 0) {
return [];
}
const spaceHasRestrictedPages =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
const hasRestriction =
spaceHasRestrictedPages &&
(await this.pagePermissionRepo.hasRestrictedAncestor(pageId));
if (!hasRestriction) {
const editCapableIds =
await this.spaceMemberRepo.getUserIdsWithSpaceEditAccess(
userIds,
spaceId,
);
return userIds.filter((id) => editCapableIds.has(id));
}
const results = await Promise.all(
userIds.map(async (userId) => {
const { canEdit } = await this.pagePermissionRepo.canUserEditPage(
userId,
pageId,
);
return canEdit ? userId : null;
}),
);
return results.filter((id): id is string => id !== null);
}
}
@@ -810,6 +810,10 @@ export class PageService {
throw new BadRequestException('Invalid move position');
}
if (dto.parentPageId && dto.parentPageId === dto.pageId) {
throw new BadRequestException('A page cannot be its own parent');
}
let parentPageId = null;
if (movedPage.parentPageId === dto.parentPageId) {
parentPageId = undefined;
@@ -15,4 +15,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsOptional()
@IsBoolean()
allowViewerComments: boolean;
@IsOptional()
@IsBoolean()
hideCommentsFromViewers: boolean;
}
@@ -30,6 +30,35 @@ import {
IAuditService,
} from '../../../integrations/audit/audit.service';
export function validateExclusiveCommentSettings(
dto: Partial<
Pick<UpdateSpaceDto, 'allowViewerComments' | 'hideCommentsFromViewers'>
>,
settingsBefore: Record<string, any>,
): void {
if (
dto.allowViewerComments === undefined &&
dto.hideCommentsFromViewers === undefined
) {
return;
}
const allowViewerComments =
dto.allowViewerComments ??
settingsBefore.comments?.allowViewerComments ??
false;
const hideCommentsFromViewers =
dto.hideCommentsFromViewers ??
settingsBefore.comments?.hideCommentsFromViewers ??
false;
if (allowViewerComments && hideCommentsFromViewers) {
throw new BadRequestException(
"'Allow viewers to comment' and 'Hide comments from viewers' cannot both be enabled",
);
}
}
@Injectable()
export class SpaceService {
constructor(
@@ -141,7 +170,8 @@ export class SpaceService {
if (
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
typeof updateSpaceDto.allowViewerComments !== 'undefined' ||
typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined'
) {
const workspace = await this.workspaceRepo.findById(workspaceId, {
withLicenseKey: true,
@@ -168,6 +198,17 @@ export class SpaceService {
) {
throw new ForbiddenException('This feature requires a valid license');
}
if (
updateSpaceDto.hideCommentsFromViewers === true &&
!this.licenseCheckService.hasFeature(
workspace.licenseKey,
Feature.HIDE_COMMENTS,
workspace.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
const spaceBefore = await this.spaceRepo.findById(
@@ -176,6 +217,8 @@ export class SpaceService {
);
const settingsBefore = (spaceBefore?.settings ?? {}) as Record<string, any>;
validateExclusiveCommentSettings(updateSpaceDto, settingsBefore);
const before: Record<string, any> = {};
const after: Record<string, any> = {};
@@ -218,6 +261,23 @@ export class SpaceService {
);
}
if (typeof updateSpaceDto.hideCommentsFromViewers !== 'undefined') {
const prev = settingsBefore?.comments?.hideCommentsFromViewers ?? false;
if (prev !== updateSpaceDto.hideCommentsFromViewers) {
before.hideCommentsFromViewers = prev;
after.hideCommentsFromViewers =
updateSpaceDto.hideCommentsFromViewers;
}
await this.spaceRepo.updateCommentSettings(
updateSpaceDto.spaceId,
workspaceId,
'hideCommentsFromViewers',
updateSpaceDto.hideCommentsFromViewers,
trx,
);
}
updatedSpace = await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
@@ -20,6 +20,7 @@ import {
CacheKey,
PERMISSION_CACHE_TTL_MS,
} from '../../../common/helpers/cache-keys';
import { SpaceRole } from '../../../common/helpers/types/permission';
@Injectable()
export class SpaceMemberRepo {
@@ -278,6 +279,32 @@ export class SpaceMemberRepo {
return new Set(rows.map((r) => r.userId));
}
async getUserIdsWithSpaceEditAccess(
userIds: string[],
spaceId: string,
): Promise<Set<string>> {
if (userIds.length === 0) return new Set();
const rows = await this.db
.selectFrom('spaceMembers')
.select('userId')
.where('userId', 'in', userIds)
.where('spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER])
.unionAll(
this.db
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select('groupUsers.userId')
.where('groupUsers.userId', 'in', userIds)
.where('spaceMembers.spaceId', '=', spaceId)
.where('spaceMembers.role', 'in', [SpaceRole.ADMIN, SpaceRole.WRITER]),
)
.execute();
return new Set(rows.map((r) => r.userId));
}
async getSpaceIdsByGroupId(groupId: string): Promise<string[]> {
const rows = await this.db
.selectFrom('spaceMembers')
@@ -149,6 +149,17 @@ export class SpaceRepo {
.executeTakeFirst();
}
async getSpaceSettings(
spaceId: string,
): Promise<Record<string, any> | null> {
const row = await this.db
.selectFrom('spaces')
.select('settings')
.where('id', '=', spaceId)
.executeTakeFirst();
return (row?.settings as Record<string, any> | undefined) ?? null;
}
async insertSpace(
insertableSpace: InsertableSpace,
trx?: KyselyTransaction,
+39 -8
View File
@@ -3,6 +3,8 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Server, Socket } from 'socket.io';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { PageAccessService } from '../core/page/page-access/page-access.service';
import {
TREE_EVENTS,
WS_SPACE_RESTRICTION_CACHE_PREFIX,
@@ -17,6 +19,8 @@ export class WsService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceRepo: SpaceRepo,
private readonly pageAccessService: PageAccessService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
@@ -67,9 +71,24 @@ export class WsService {
spaceId: string,
pageId: string,
data: any,
opts?: { bypassVisibilityCheck?: boolean },
): Promise<void> {
const room = getSpaceRoomName(spaceId);
if (
!opts?.bypassVisibilityCheck &&
(await this.spaceHidesCommentsFromViewers(spaceId))
) {
await this.broadcastToUsersMatching(room, null, data, (candidateIds) =>
this.pageAccessService.filterUserIdsWithPageEditAccess(
spaceId,
pageId,
candidateIds,
),
);
return;
}
const hasRestrictions = await this.spaceHasRestrictions(spaceId);
if (!hasRestrictions) {
this.server.to(room).emit('message', data);
@@ -118,6 +137,17 @@ export class WsService {
excludeSocketId: string | null,
pageId: string,
data: any,
): Promise<void> {
await this.broadcastToUsersMatching(room, excludeSocketId, data, (ids) =>
this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, ids),
);
}
private async broadcastToUsersMatching(
room: string,
excludeSocketId: string | null,
data: any,
filterUserIds: (candidateUserIds: string[]) => Promise<string[]>,
): Promise<void> {
const sockets = await this.server.in(room).fetchSockets();
@@ -144,15 +174,9 @@ export class WsService {
const candidateUserIds = Array.from(userSocketMap.keys());
if (candidateUserIds.length === 0) return;
const authorizedUserIds =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
candidateUserIds,
);
const authorizedSet = new Set(authorizedUserIds);
const allowedSet = new Set(await filterUserIds(candidateUserIds));
for (const [userId, userSockets] of userSocketMap) {
if (authorizedSet.has(userId)) {
if (allowedSet.has(userId)) {
for (const socket of userSockets) {
socket.emit('message', data);
}
@@ -176,6 +200,13 @@ export class WsService {
return hasRestrictions;
}
private async spaceHidesCommentsFromViewers(
spaceId: string,
): Promise<boolean> {
const settings = await this.spaceRepo.getSpaceSettings(spaceId);
return settings?.comments?.hideCommentsFromViewers === true;
}
private extractPageId(data: any): string | null {
switch (data.operation) {
case 'addTreeNode':
+9 -9
View File
@@ -23,11 +23,11 @@
"@casl/ability": "6.8.0",
"@docmost/editor-ext": "workspace:*",
"@floating-ui/dom": "1.7.3",
"@hocuspocus/common": "4.4.0",
"@hocuspocus/provider": "4.4.0",
"@hocuspocus/provider-react": "4.4.0",
"@hocuspocus/server": "4.4.0",
"@hocuspocus/transformer": "4.4.0",
"@hocuspocus/common": "4.5.0",
"@hocuspocus/provider": "4.5.0",
"@hocuspocus/provider-react": "4.5.0",
"@hocuspocus/server": "4.5.0",
"@hocuspocus/transformer": "4.5.0",
"@joplin/turndown": "4.0.82",
"@joplin/turndown-plugin-gfm": "1.0.64",
"@sindresorhus/slugify": "3.0.0",
@@ -65,7 +65,7 @@
"date-fns": "4.1.0",
"diff": "8.0.3",
"docx": "9.7.1",
"dompurify": "3.4.11",
"dompurify": "3.4.13",
"fractional-indexing-jittered": "1.0.0",
"highlight.js": "11.11.1",
"image-dimensions": "2.5.0",
@@ -81,12 +81,12 @@
"yjs": "^13.6.30"
},
"devDependencies": {
"@nx/js": "22.6.1",
"@nx/js": "23.1.1",
"@types/bytes": "3.1.5",
"@types/qrcode": "1.5.6",
"@types/turndown": "5.0.6",
"concurrently": "9.2.3",
"nx": "22.6.1",
"concurrently": "10.0.4",
"nx": "23.1.1",
"tsx": "^4.21.0"
},
"workspaces": {
+1732 -1359
View File
File diff suppressed because it is too large Load Diff
+11 -37
View File
@@ -5,54 +5,28 @@ patchedDependencies:
scimmy@1.3.5: patches/scimmy@1.3.5.patch
overrides:
prosemirror-changeset: 2.4.0
y-prosemirror: 1.3.7
glob: 13.0.6
ws: 8.21.0
dompurify: 3.4.11
dompurify: 3.4.13
mermaid: 11.16.1
undici: 7.29.0
tmp: 0.2.7
hono: 4.12.25
mermaid: 11.15.0
nanoid@^3: 3.3.8
socket.io-parser: 4.2.6
serialize-javascript: 7.0.3
nanoid@^3: 3.3.17
lodash-es: 4.18.1
lodash: 4.18.1
'@hono/node-server': 1.19.13
undici: 7.28.0
ajv@^6: 6.14.0
ajv@^8: 8.18.0
underscore: 1.13.8
immutable: 4.3.8
express-rate-limit: 8.2.2
minimatch@^3: 3.1.5
minimatch@^5: 5.1.8
flatted: 3.4.2
picomatch@<2.3.2: 2.3.2
picomatch@>=4.0.0 <4.0.4: 4.0.4
fastify: 5.8.5
yaml@>=1.0.0 <1.10.3: 1.10.3
find-my-way: 9.7.0
yaml@>=2.0.0 <2.8.3: 2.8.3
path-to-regexp@^8: 8.4.0
brace-expansion@^5: 5.0.6
'@xmldom/xmldom': 0.8.13
handlebars: 4.7.9
brace-expansion@^5: 5.0.9
axios: 1.18.1
langsmith: 0.7.0
follow-redirects: 1.16.0
protobufjs: 7.5.8
ip-address: 10.1.1
fast-uri: 3.1.3
ip-address: 10.3.1
fast-uri: 3.1.5
form-data@>=4.0.0 <4.0.6: 4.0.6
nanoid@>=4.0.0 <5.0.9: 5.1.16
qs: 6.15.3
nanoid@>=4.0.0 <5.1.16: 5.1.16
esbuild@>=0.27.3 <0.28.1: 0.28.1
'@babel/core@<=7.29.0': 7.29.7
'@opentelemetry/core@>=2.0.0 <2.8.0': 2.9.0
'@babel/plugin-transform-modules-systemjs@<=7.29.3': 7.29.7
brace-expansion@<1.1.13: 1.1.15
brace-expansion@>=2.0.0 <2.0.3: 2.0.3
js-yaml@>=3.0.0 <3.15.0: 3.15.0
js-yaml@>=4.0.0 <=4.1.1: 4.3.0
js-yaml@>=3.0.0 <3.15.1: 3.15.1
js-yaml@>=4.0.0 <4.3.1: 4.3.1
shamefullyHoist: true
minimumReleaseAge: 4320
allowBuilds: