Compare commits

...

24 Commits

Author SHA1 Message Date
Philipinho 8768e0e6c9 fix 2026-03-27 21:51:58 +00:00
Philipinho dc75fddd9c feat(ee): viewers comment settings 2026-03-27 21:43:12 +00:00
Philipinho a3559b7c33 sync 2026-03-26 20:01:02 +00:00
Philip Okugbe 803f1f0b81 feat: user session management (#2056)
* user session management

* WIP

* cleanup

* license

* cleanup

* don't cache index

* rename current device property

* fix
2026-03-26 20:00:04 +00:00
Philipinho 4e8f533b91 override 2026-03-26 16:48:33 +00:00
Philipinho 7b0d8fe140 override 2026-03-26 16:46:40 +00:00
Philipinho 2f92278a9d sync 2026-03-26 16:35:05 +00:00
Philipinho 53608eae35 clean up ws 2026-03-26 13:59:17 +00:00
Philipinho 0e4a1e7419 enum validation 2026-03-26 00:41:38 +00:00
Philipinho 9125996e97 sync 2026-03-25 10:08:36 +00:00
Philip Okugbe fa4872e89e fix(deps): package updates (#2041)
* update
* overrides
* override
* fix page update mutation
* fix
* cleanup
* loader
* fix excalidraw package
* override
* fix regex
2026-03-25 10:07:01 +00:00
Philipinho 6d6f3a8a8e merge commit 2026-03-24 10:52:09 +00:00
Philip Okugbe 975b4dcaab feat: auth pages layout (#2042)
* auth pages layout
* exclude home route from redirect
* fix margin
2026-03-22 16:40:50 +00:00
Philip Okugbe 6683c515cf fix: make codeblock language detection performant (#2032)
* fix: make codeblock language detection performant
* lint
2026-03-17 20:40:22 +00:00
Philipinho cc5c800238 0.70.3 2026-03-17 14:29:09 +00:00
Philipinho cfaee93af9 fix 2026-03-17 14:28:22 +00:00
Philipinho 74eddb0638 v0.70.2 2026-03-16 13:49:50 +00:00
Philipinho 7c83a9d4f0 update dompurify 2026-03-16 13:49:20 +00:00
Philipinho 2678c4e279 fix 2026-03-16 00:32:30 +00:00
Philipinho b0bde4b375 feat: replace link popover with dedicated bubble menu 2026-03-16 00:26:03 +00:00
Philipinho 724e37d5b7 revert 2026-03-15 23:03:32 +00:00
Philipinho 33184e9d8d sync 2026-03-15 22:07:26 +00:00
Philip Okugbe 7520c329d0 fix notion importer (#2027)
* fix notion importer

* fix link selector on mobile
2026-03-15 22:06:40 +00:00
Philip Okugbe d7a5fda53c feat: better feature flags (#2026)
* feat: feature flag upgrade

* fix translations

* refactor

* fix

* fix
2026-03-15 22:05:32 +00:00
147 changed files with 7146 additions and 4704 deletions
+39 -39
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.70.1", "version": "0.70.3",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -10,76 +10,76 @@
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\"" "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.ts\""
}, },
"dependencies": { "dependencies": {
"@casl/react": "^4.0.0", "@casl/react": "^5.0.1",
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-3a5ef40", "@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.14", "@mantine/core": "^8.3.18",
"@mantine/dates": "^8.3.14", "@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.14", "@mantine/form": "^8.3.18",
"@mantine/hooks": "^8.3.14", "@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.14", "@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.14", "@mantine/notifications": "^8.3.18",
"@mantine/spotlight": "^8.3.14", "@mantine/spotlight": "^8.3.18",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.5", "axios": "^1.13.6",
"blueimp-load-image": "^5.16.0", "blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0", "highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.16.8", "i18next": "^25.10.1",
"i18next-http-backend": "^2.7.3", "i18next-http-backend": "^3.0.2",
"jotai": "^2.16.2", "jotai": "^2.18.1",
"jotai-optics": "^0.4.0", "jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"katex": "0.16.27", "katex": "0.16.40",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2", "mermaid": "^11.13.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "1.345.5", "posthog-js": "1.363.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17", "react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^1.0.7", "react-drawio": "^1.0.7",
"react-error-boundary": "^4.1.2", "react-error-boundary": "^6.1.1",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^3.0.0",
"react-i18next": "^15.0.1", "react-i18next": "^16.5.8",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.13.1",
"semver": "^7.7.3", "semver": "^7.7.4",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.28.0",
"@tanstack/eslint-plugin-query": "^5.62.1", "@tanstack/eslint-plugin-query": "^5.94.4",
"@types/blueimp-load-image": "^5.16.0", "@types/blueimp-load-image": "^5.16.6",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.8",
"@types/node": "22.19.1", "@types/node": "22.19.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.2", "eslint": "^9.28.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0", "globals": "^15.13.0",
"optics-ts": "^2.4.1", "optics-ts": "^2.4.1",
"postcss": "^8.4.49", "postcss": "^8.5.8",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.4.1", "prettier": "^3.8.1",
"typescript": "^5.7.2", "typescript": "^5.9.3",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.57.1",
"vite": "^7.2.4" "vite": "^8.0.1"
} }
} }
@@ -442,9 +442,11 @@
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.", "Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing", "Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing", "Toggle space public sharing": "Toggle space public sharing",
"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",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", "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.", "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Requires an enterprise license": "Requires an enterprise license",
"Page permissions": "Page permissions", "Page permissions": "Page permissions",
"Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.", "Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.",
"Enable public sharing": "Enable public sharing", "Enable public sharing": "Enable public sharing",
@@ -626,7 +628,9 @@
"Generative AI (Ask AI)": "Generative AI (Ask AI)", "Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.", "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI", "Toggle generative AI": "Toggle generative AI",
"Enterprise feature": "Enterprise feature", "Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"AI & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
@@ -634,17 +638,15 @@
"Model Context Protocol (MCP)": "Model Context Protocol (MCP)", "Model Context Protocol (MCP)": "Model Context Protocol (MCP)",
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP documentation": "MCP documentation",
"MCP Server URL": "MCP Server URL", "MCP Server URL": "MCP Server URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.", "Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
"Supported tools": "Supported tools", "Supported tools": "Supported tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.", "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"MCP server URL:": "MCP server URL:", "MCP server URL:": "MCP server URL:",
"Learn more": "Learn more", "Learn more": "Learn more",
"View the": "View the", "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
"for usage details.": "for usage details.", "View the <anchor>API documentation</anchor> for usage details.": "View the <anchor>API documentation</anchor> for usage details.",
"for setup instructions.": "for setup instructions.", "View the <anchor>MCP documentation</anchor>.": "View the <anchor>MCP documentation</anchor>.",
"API documentation": "API documentation",
"Sources": "Sources", "Sources": "Sources",
"AI Answers not available for attachments": "AI Answers not available for attachments", "AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available", "No answer available": "No answer available",
@@ -659,12 +661,12 @@
"Mark all as read": "Mark all as read", "Mark all as read": "Mark all as read",
"Mark as read": "Mark as read", "Mark as read": "Mark as read",
"More options": "More options", "More options": "More options",
"mentioned you in a comment": "mentioned you in a comment", "<bold>{{name}}</bold> mentioned you in a comment": "<bold>{{name}}</bold> mentioned you in a comment",
"commented on a page": "commented on a page", "<bold>{{name}}</bold> commented on a page": "<bold>{{name}}</bold> commented on a page",
"resolved a comment": "resolved a comment", "<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> resolved a comment",
"mentioned you on a page": "mentioned you on a page", "<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page",
"gave you edit access to a page": "gave you edit access to a page", "<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page",
"gave you view access to a page": "gave you view access to a page", "<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page",
"Today": "Today", "Today": "Today",
"Yesterday": "Yesterday", "Yesterday": "Yesterday",
"This week": "This week", "This week": "This week",
@@ -709,5 +711,20 @@
"Resend verification email": "Resend verification email", "Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.", "Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.", "Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces." "We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.",
"Load more": "Load more",
"Log out of all devices": "Log out of all devices",
"Log out of all sessions except this device": "Log out of all sessions except this device",
"This Device": "This Device",
"Unknown device": "Unknown device",
"No active sessions": "No active sessions",
"Session revoked": "Session revoked",
"All other sessions revoked": "All other sessions revoked",
"Last used": "Last used",
"Created": "Created",
"Rename": "Rename",
"Publish": "Publish",
"Security": "Security",
"Enforce SSO": "Enforce SSO",
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password."
} }
@@ -21,7 +21,9 @@ import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { import {
prefetchApiKeyManagement, prefetchApiKeyManagement,
prefetchApiKeys, prefetchApiKeys,
@@ -39,22 +41,19 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
interface DataItem { type DataItem = {
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
path: string; path: string;
isCloud?: boolean; feature?: string;
isEnterprise?: boolean; role?: "admin" | "owner";
isAdmin?: boolean; env?: "cloud" | "selfhosted";
isOwner?: boolean; };
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
interface DataGroup { type DataGroup = {
heading: string; heading: string;
items: DataItem[]; items: DataItem[];
} };
const groupedData: DataGroup[] = [ const groupedData: DataGroup[] = [
{ {
@@ -70,9 +69,7 @@ const groupedData: DataGroup[] = [
label: "API keys", label: "API keys",
icon: IconKey, icon: IconKey,
path: "/settings/account/api-keys", path: "/settings/account/api-keys",
isCloud: true, feature: Feature.API_KEYS,
isEnterprise: true,
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -80,26 +77,20 @@ const groupedData: DataGroup[] = [
heading: "Workspace", heading: "Workspace",
items: [ items: [
{ label: "General", icon: IconSettings, path: "/settings/workspace" }, { label: "General", icon: IconSettings, path: "/settings/workspace" },
{ { label: "Members", icon: IconUsers, path: "/settings/members" },
label: "Members",
icon: IconUsers,
path: "/settings/members",
},
{ {
label: "Billing", label: "Billing",
icon: IconCoin, icon: IconCoin,
path: "/settings/billing", path: "/settings/billing",
isCloud: true, role: "admin",
isAdmin: true, env: "cloud",
}, },
{ {
label: "Security & SSO", label: "Security & SSO",
icon: IconLock, icon: IconLock,
path: "/settings/security", path: "/settings/security",
isCloud: true, feature: Feature.SECURITY_SETTINGS,
isEnterprise: true, role: "admin",
isAdmin: true,
showDisabledInNonEE: true,
}, },
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
@@ -108,25 +99,22 @@ const groupedData: DataGroup[] = [
label: "API management", label: "API management",
icon: IconKey, icon: IconKey,
path: "/settings/api-keys", path: "/settings/api-keys",
isCloud: true, feature: Feature.API_KEYS,
isEnterprise: true, role: "admin",
isAdmin: true,
showDisabledInNonEE: true,
}, },
{ {
label: "AI settings", label: "AI settings",
icon: IconSparkles, icon: IconSparkles,
path: "/settings/ai", path: "/settings/ai",
isAdmin: true, role: "admin",
}, },
{ {
label: "Audit log", label: "Audit log",
icon: IconHistory, icon: IconHistory,
path: "/settings/audit", path: "/settings/audit",
isEnterprise: true, feature: Feature.AUDIT_LOGS,
isOwner: true, role: "owner",
isSelfhosted: true, env: "selfhosted",
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -148,7 +136,8 @@ export default function SettingsSidebar() {
const [active, setActive] = useState(location.pathname); const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation(); const { goBack } = useSettingsNavigation();
const { isAdmin, isOwner } = useUserRole(); const { isAdmin, isOwner } = useUserRole();
const [workspace] = useAtom(workspaceAtom); const [entitlements] = useAtom(entitlementAtom);
const upgradeLabel = useUpgradeLabel();
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -156,43 +145,20 @@ export default function SettingsSidebar() {
setActive(location.pathname); setActive(location.pathname);
}, [location.pathname]); }, [location.pathname]);
const hasRoleAccess = (item: DataItem) => { const hasFeature = (f: string) =>
if (item.isOwner) return isOwner; entitlements?.features?.includes(f) ?? false;
if (item.isAdmin) return isAdmin;
const canShowItem = (item: DataItem) => {
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false;
return true; return true;
}; };
const canShowItem = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) {
if (item.isSelfhosted && isCloud()) return false;
return hasRoleAccess(item);
}
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return hasRoleAccess(item);
}
if (item.isCloud) {
return isCloud() ? hasRoleAccess(item) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? hasRoleAccess(item) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? hasRoleAccess(item) : false;
}
return hasRoleAccess(item);
};
const isItemDisabled = (item: DataItem) => { const isItemDisabled = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) { if (!item.feature) return false;
return !(isCloud() || workspace?.hasLicenseKey); return !hasFeature(item.feature);
}
return false;
}; };
const menuItems = groupedData.map((group) => { const menuItems = groupedData.map((group) => {
@@ -225,7 +191,7 @@ export default function SettingsSidebar() {
prefetchHandler = prefetchBilling; prefetchHandler = prefetchBilling;
break; break;
case "License & Edition": case "License & Edition":
if (workspace?.hasLicenseKey) { if (entitlements?.tier !== "free") {
prefetchHandler = prefetchLicense; prefetchHandler = prefetchLicense;
} }
break; break;
@@ -280,7 +246,7 @@ export default function SettingsSidebar() {
return ( return (
<Tooltip <Tooltip
key={item.label} key={item.label}
label={t("Available in enterprise edition")} label={upgradeLabel}
position="right" position="right"
withArrow withArrow
> >
@@ -1,12 +1,13 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core"; import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import useLicense from "@/ee/hooks/use-license.tsx"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiSearch() { export default function EnableAiSearch() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,9 +38,8 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search); const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -56,14 +56,16 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
}; };
return ( return (
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
size={size} <Switch
label={label} size={size}
labelPosition="left" label={label}
defaultChecked={checked} labelPosition="left"
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
aria-label={t("Toggle AI search")} disabled={!hasAccess}
/> aria-label={t("Toggle AI search")}
/>
</Tooltip>
); );
} }
@@ -1,17 +1,20 @@
import { Group, Text, Switch } from "@mantine/core"; import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableGenerativeAi() { export default function EnableGenerativeAi() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative); const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -38,11 +41,13 @@ export default function EnableGenerativeAi() {
</Text> </Text>
</div> </div>
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
defaultChecked={checked} <Switch
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
/> disabled={!hasAccess}
/>
</Tooltip>
</Group> </Group>
); );
} }
@@ -13,10 +13,12 @@ import {
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts"; import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx"; import { CopyButton } from "@/components/common/copy-button.tsx";
@@ -25,7 +27,8 @@ export default function McpSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp); const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const mcpUrl = `${getAppUrl()}/mcp`; const mcpUrl = `${getAppUrl()}/mcp`;
@@ -46,11 +49,7 @@ export default function McpSettings() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{!hasAccess && ( {!hasAccess && (
<Alert <Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
>
{t( {t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)} )}
@@ -64,23 +63,22 @@ export default function McpSettings() {
{t( {t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
)}{" "} )}{" "}
{t("View the")}{" "} <Trans
<Anchor i18nKey="View the <anchor>MCP documentation</anchor>."
href="https://docmost.com/docs/user-guide/mcp" components={{
target="_blank" anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />,
size="sm" }}
> />
{t("MCP documentation")}
</Anchor>
.
</Text> </Text>
</div> </div>
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
defaultChecked={checked} <Switch
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
/> disabled={!hasAccess}
/>
</Tooltip>
</Group> </Group>
{checked && ( {checked && (
@@ -89,11 +87,7 @@ export default function McpSettings() {
{t("MCP Server URL")} {t("MCP Server URL")}
</Text> </Text>
<Group gap="xs"> <Group gap="xs">
<TextInput <TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<CopyButton value={mcpUrl} timeout={2000}> <CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip <Tooltip
@@ -123,12 +117,36 @@ export default function McpSettings() {
{t("Supported tools")} {t("Supported tools")}
</Text> </Text>
<List size="sm" spacing={2}> <List size="sm" spacing={2}>
<List.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</Text></List.Item> <List.Item>
<List.Item><Text size="sm" c="dimmed" span>list_pages, list_child_pages, duplicate_page</Text></List.Item> <Text size="sm" c="dimmed" span>
<List.Item><Text size="sm" c="dimmed" span>copy_page_to_space, move_page, move_page_to_space</Text></List.Item> search_pages, get_page, create_page, update_page
<List.Item><Text size="sm" c="dimmed" span>get_space, list_spaces, create_space, update_space</Text></List.Item> </Text>
<List.Item><Text size="sm" c="dimmed" span>get_comments, create_comment, update_comment</Text></List.Item> </List.Item>
<List.Item><Text size="sm" c="dimmed" span>search_attachments, list_workspace_members, get_current_user</Text></List.Item> <List.Item>
<Text size="sm" c="dimmed" span>
list_pages, list_child_pages, duplicate_page
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
copy_page_to_space, move_page, move_page_to_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_space, list_spaces, create_space, update_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_comments, create_comment, update_comment
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
search_attachments, list_workspace_members, get_current_user
</Text>
</List.Item>
</List> </List>
</div> </div>
</div> </div>
+6 -3
View File
@@ -9,14 +9,17 @@ import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx"; import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core"; import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() { export default function AiSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -55,7 +58,7 @@ export default function AiSettings() {
{!hasAccess && ( {!hasAccess && (
<Alert <Alert
icon={<IconInfoCircle />} icon={<IconInfoCircle />}
title={t("Enterprise feature")} title={upgradeLabel}
color="blue" color="blue"
mb="lg" mb="lg"
> >
@@ -5,12 +5,14 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { import {
ResponsiveSettingsRow, ResponsiveSettingsRow,
ResponsiveSettingsContent, ResponsiveSettingsContent,
ResponsiveSettingsControl, ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row"; } from "@/components/ui/responsive-settings-row";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function RestrictApiToAdmins() { export default function RestrictApiToAdmins() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,7 +20,8 @@ export default function RestrictApiToAdmins() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true, workspace?.settings?.api?.restrictToAdmins === true,
); );
const hasAccess = useEnterpriseAccess(); const hasAccess = useHasFeature(Feature.API_KEYS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -51,7 +54,7 @@ export default function RestrictApiToAdmins() {
<ResponsiveSettingsControl> <ResponsiveSettingsControl>
<Tooltip <Tooltip
label={t("Requires an enterprise license")} label={upgradeLabel}
disabled={hasAccess} disabled={hasAccess}
refProp="rootRef" refProp="rootRef"
> >
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core"; import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config"; import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -58,11 +58,12 @@ export default function UserApiKeys() {
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
{t("View the")}{" "} <Trans
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm"> i18nKey="View the <anchor>API documentation</anchor> for usage details."
{t("API documentation")} components={{
</Anchor>{" "} anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
{t("for usage details.")} }}
/>
</Text> </Text>
{mcpEnabled && canCreate && ( {mcpEnabled && canCreate && (
@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core"; import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config"; import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -56,12 +56,12 @@ export default function WorkspaceApiKeys() {
<SettingsTitle title={t("API management")} /> <SettingsTitle title={t("API management")} />
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace.")}{" "} <Trans
{t("View the")}{" "} i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details."
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm"> components={{
{t("API documentation")} anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
</Anchor>{" "} }}
{t("for usage details.")} />
</Text> </Text>
<RestrictApiToAdmins /> <RestrictApiToAdmins />
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx"; import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts"; import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts"; import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
const formSchema = z.object({ const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }), hostname: z.string().min(1, { message: "subdomain is required" }),
@@ -82,7 +83,7 @@ export function CloudLoginForm() {
} }
return ( return (
<div> <AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
@@ -145,12 +146,12 @@ export function CloudLoginForm() {
</Box> </Box>
</Container> </Container>
<Text ta="center"> <Text ta="center" mb="xl">
{t("Don't have a workspace?")}{" "} {t("Don't have a workspace?")}{" "}
<Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}> <Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}>
{t("Create new workspace")} {t("Create new workspace")}
</Anchor> </Anchor>
</Text> </Text>
</div> </AuthLayout>
); );
} }
+1 -2
View File
@@ -6,7 +6,6 @@ import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts"; import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx"; import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() { export default function SsoLogin() {
@@ -57,7 +56,7 @@ export default function SsoLogin() {
/> />
)} )}
{(isCloud() || data.hasLicenseKey) && ( {data.authProviders.length > 0 && (
<> <>
<Stack align="stretch" justify="center" gap="sm"> <Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => ( {data.authProviders.map((provider) => (
@@ -0,0 +1,7 @@
import { atomWithStorage } from "jotai/utils";
import type { Entitlements } from "./entitlement.types";
export const entitlementAtom = atomWithStorage<Entitlements | null>(
"entitlements",
null,
);
@@ -0,0 +1,7 @@
import api from "@/lib/api-client";
import { Entitlements } from "./entitlement.types";
export async function getEntitlements(): Promise<Entitlements> {
const req = await api.post<Entitlements>("/workspace/entitlements");
return req.data as Entitlements;
}
@@ -0,0 +1,7 @@
export type Tier = "free" | "standard" | "business" | "enterprise";
export type Entitlements = {
cloud: boolean;
tier: Tier;
features: string[];
};
@@ -0,0 +1,11 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getEntitlements } from "./entitlement-service";
import { Entitlements } from "./entitlement.types";
export function useEntitlements(): UseQueryResult<Entitlements> {
return useQuery({
queryKey: ["entitlements"],
queryFn: getEntitlements,
staleTime: 5 * 60 * 1000,
});
}
+20
View File
@@ -0,0 +1,20 @@
export const Feature = {
SSO_CUSTOM: 'sso:custom',
SSO_GOOGLE: 'sso:google',
MFA: 'mfa',
API_KEYS: 'api:keys',
COMMENT_RESOLUTION: 'comment:resolution',
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
@@ -1,12 +0,0 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
+7
View File
@@ -0,0 +1,7 @@
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export const useHasFeature = (feature: string): boolean => {
const [entitlements] = useAtom(entitlementAtom);
return entitlements?.features?.includes(feature) ?? false;
};
-9
View File
@@ -1,9 +0,0 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export const useLicense = () => {
const [currentUser] = useAtom(currentUserAtom);
return { hasLicenseKey: currentUser?.workspace?.hasLicenseKey };
};
export default useLicense;
@@ -0,0 +1,16 @@
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { isCloud } from "@/lib/config";
export function useUpgradeLabel(): string {
const { t } = useTranslation();
const [entitlements] = useAtom(entitlementAtom);
if (!isCloud()) {
return entitlements != null && entitlements.tier !== "free"
? t("Upgrade your license tier.")
: t("Available with a paid license");
}
return t("Upgrade your plan");
}
@@ -1,27 +1,28 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import React from "react"; import React, { useRef } from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core"; import { Button, Divider, Group, Modal, Stack, Textarea } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver"; import { zod4Resolver } from "mantine-form-zod-resolver";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts"; import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import RemoveLicense from "@/ee/licence/components/remove-license.tsx"; import RemoveLicense from "@/ee/licence/components/remove-license.tsx";
export default function ActivateLicense() { export default function ActivateLicense() {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom); const [entitlements] = useAtom(entitlementAtom);
const hasLicense = entitlements != null && entitlements.tier !== "free";
return ( return (
<Group justify="flex-end" wrap="nowrap" mb="sm"> <Group justify="flex-end" wrap="nowrap" mb="sm">
<Button onClick={open}> <Button onClick={open}>
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")} {hasLicense ? t("Update license") : t("Add license")}
</Button> </Button>
{workspace?.hasLicenseKey && <RemoveLicense />} {hasLicense && <RemoveLicense />}
<Modal <Modal
size="550" size="550"
@@ -48,6 +49,7 @@ interface ActivateLicenseFormProps {
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) { export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const activateLicenseMutation = useActivateMutation(); const activateLicenseMutation = useActivateMutation();
const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
@@ -59,32 +61,71 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
async function handleSubmit(data: { licenseKey: string }) { async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey); await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset(); form.reset();
onClose(); onClose?.();
}
function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = (e.target?.result as string)?.trim();
if (content) {
form.setFieldValue("licenseKey", content);
handleSubmit({ licenseKey: content });
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
} }
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Textarea <input
label={t("License key")} type="file"
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one." accept=".txt"
placeholder={t("e.g eyJhb.....")} ref={fileInputRef}
variant="filled" onChange={handleFileUpload}
autosize hidden
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
/> />
<Group justify="flex-end" mt="md"> <Stack gap="xs">
<Button <Textarea
type="submit" label={t("License key")}
disabled={activateLicenseMutation.isPending} placeholder={t("e.g eyJhb.....")}
loading={activateLicenseMutation.isPending} variant="filled"
> autosize
{t("Save")} minRows={3}
</Button> maxRows={5}
</Group> data-autofocus
{...form.getInputProps("licenseKey")}
/>
<Group justify="flex-end">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
<Divider label={t("Or")} labelPosition="center" />
<Group justify="center">
<Button
variant="light"
onClick={() => fileInputRef.current?.click()}
>
{t("Upload license file")}
</Button>
</Group>
</Stack>
</form> </form>
); );
} }
@@ -31,7 +31,8 @@ export default function LicenseDetails() {
<Table.Tr> <Table.Tr>
<Table.Th w={160}>Edition</Table.Th> <Table.Th w={160}>Edition</Table.Th>
<Table.Td> <Table.Td>
Enterprise {license.trial && <Badge color="green">Trial</Badge>} {license.licenseType === "business" ? "Business" : "Enterprise"}{" "}
{license.trial && <Badge color="green">Trial</Badge>}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -68,7 +68,11 @@ export default function OssDetails() {
</List> </List>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Contact <a href="mailto:sales@docmost.com?subject=Enterprise%20License%20Inquiry">sales@docmost.com </a> to purchase an enterprise license. Get an enterprise trial key at <a href="https://customers.docmost.com/" target="_blank" rel="noopener noreferrer">customers.docmost.com</a>.
</Text>
<Text size="sm" c="dimmed">
Visit <a href="https://docmost.com/pricing" target="_blank" rel="noopener noreferrer">docmost.com/pricing</a> to purchase an enterprise license.
</Text> </Text>
</Stack> </Stack>
</Stack> </Stack>
+4 -3
View File
@@ -8,10 +8,11 @@ import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.
import InstallationDetails from "@/ee/licence/components/installation-details.tsx"; import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
import OssDetails from "@/ee/licence/components/oss-details.tsx"; import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export default function License() { export default function License() {
const [workspace] = useAtom(workspaceAtom); const [entitlements] = useAtom(entitlementAtom);
const hasLicense = entitlements != null && entitlements.tier !== "free";
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
if (!isAdmin) { if (!isAdmin) {
@@ -29,7 +30,7 @@ export default function License() {
<InstallationDetails /> <InstallationDetails />
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />} {hasLicense ? <LicenseDetails /> : <OssDetails />}
</> </>
); );
} }
@@ -31,6 +31,7 @@ export function useActivateMutation() {
queryKey: ["license"], queryKey: ["license"],
}); });
queryClient.refetchQueries({ queryKey: ["currentUser"] }); queryClient.refetchQueries({ queryKey: ["currentUser"] });
queryClient.refetchQueries({ queryKey: ["entitlements"] });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;
@@ -47,6 +48,7 @@ export function useRemoveLicenseMutation() {
onSuccess: () => { onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["license"] }); queryClient.refetchQueries({ queryKey: ["license"] });
queryClient.refetchQueries({ queryKey: ["currentUser"] }); queryClient.refetchQueries({ queryKey: ["currentUser"] });
queryClient.refetchQueries({ queryKey: ["entitlements"] });
}, },
}); });
} }
@@ -1,7 +1,10 @@
export type LicenseType = 'business' | 'enterprise';
export interface ILicenseInfo { export interface ILicenseInfo {
id: string; id: string;
customerName: string; customerName: string;
seatCount: number; seatCount: number;
licenseType: LicenseType;
issuedAt: Date; issuedAt: Date;
expiresAt: Date; expiresAt: Date;
trial: boolean; trial: boolean;
@@ -22,6 +22,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { MfaBackupCodeInput } from "./mfa-backup-code-input"; import { MfaBackupCodeInput } from "./mfa-backup-code-input";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
const formSchema = z.object({ const formSchema = z.object({
code: z code: z
@@ -66,6 +67,7 @@ export function MfaChallenge() {
}; };
return ( return (
<AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Paper radius="lg" p={40} className={classes.paper}> <Paper radius="lg" p={40} className={classes.paper}>
<Stack align="center" gap="xl"> <Stack align="center" gap="xl">
@@ -157,5 +159,6 @@ export function MfaChallenge() {
</Stack> </Stack>
</Paper> </Paper>
</Container> </Container>
</AuthLayout>
); );
} }
@@ -7,8 +7,9 @@ import { getMfaStatus } from "@/ee/mfa";
import { MfaSetupModal } from "@/ee/mfa"; import { MfaSetupModal } from "@/ee/mfa";
import { MfaDisableModal } from "@/ee/mfa"; import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa"; import { MfaBackupCodesModal } from "@/ee/mfa";
import { isCloud } from "@/lib/config.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import useLicense from "@/ee/hooks/use-license.tsx"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row"; import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
export function MfaSettings() { export function MfaSettings() {
@@ -17,7 +18,8 @@ export function MfaSettings() {
const [setupModalOpen, setSetupModalOpen] = useState(false); const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false); const [disableModalOpen, setDisableModalOpen] = useState(false);
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false); const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
const { hasLicenseKey } = useLicense(); const canUseMfa = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const { data: mfaStatus, isLoading } = useQuery({ const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"], queryKey: ["mfa-status"],
@@ -28,8 +30,6 @@ export function MfaSettings() {
return null; return null;
} }
const canUseMfa = isCloud() || hasLicenseKey;
// Check if MFA is truly enabled // Check if MFA is truly enabled
const isMfaEnabled = mfaStatus?.isEnabled === true; const isMfaEnabled = mfaStatus?.isEnabled === true;
@@ -69,7 +69,7 @@ export function MfaSettings() {
<ResponsiveSettingsControl> <ResponsiveSettingsControl>
{!isMfaEnabled ? ( {!isMfaEnabled ? (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseMfa} disabled={canUseMfa}
> >
<Button <Button
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa"; import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
export default function MfaSetupRequired() { export default function MfaSetupRequired() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,6 +16,7 @@ export default function MfaSetupRequired() {
}; };
return ( return (
<AuthLayout>
<Container size="sm" py="xl"> <Container size="sm" py="xl">
<Paper shadow="sm" p="xl" radius="md" withBorder> <Paper shadow="sm" p="xl" radius="md" withBorder>
<Stack> <Stack>
@@ -44,5 +46,6 @@ export default function MfaSetupRequired() {
</Stack> </Stack>
</Paper> </Paper>
</Container> </Container>
</AuthLayout>
); );
} }
@@ -19,7 +19,8 @@ import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-p
import { PagePermissionTab } from "@/ee/page-permission"; import { PagePermissionTab } from "@/ee/page-permission";
import { PublishTab } from "./publish-tab"; import { PublishTab } from "./publish-tab";
import { useShareForPageQuery } from "@/features/share/queries/share-query"; import { useShareForPageQuery } from "@/features/share/queries/share-query";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { useSpaceQuery } from "@/features/space/queries/space-query"; import { useSpaceQuery } from "@/features/space/queries/space-query";
@@ -33,9 +34,9 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const { pageSlug, spaceSlug } = useParams(); const { pageSlug, spaceSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug); const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const isCloudEE = useIsCloudEE(); const hasPagePermissions = useHasFeature(Feature.PAGE_PERMISSIONS);
const [activeTab, setActiveTab] = useState<string | null>( const [activeTab, setActiveTab] = useState<string | null>(
isCloudEE ? "access" : "publish", hasPagePermissions ? "access" : "publish",
); );
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
@@ -51,7 +52,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const isPubliclyShared = !!share; const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } = const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined); usePageRestrictionInfoQuery(opened && hasPagePermissions ? pageId : undefined);
return ( return (
<> <>
@@ -92,7 +93,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="access"> <Tabs.Panel value="access">
{!isCloudEE ? ( {!hasPagePermissions ? (
<Stack align="center" py="md"> <Stack align="center" py="md">
<IconLock size={20} stroke={1.5} /> <IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}> <Text size="sm" ta="center" fw={500}>
+15 -10
View File
@@ -9,6 +9,7 @@ import {
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import APP_ROUTE from "@/lib/app-route.ts"; import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
export default function VerifyEmail() { export default function VerifyEmail() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -59,20 +60,23 @@ export default function VerifyEmail() {
if (token) { if (token) {
return ( return (
<Container size={420} className={classes.container}> <AuthLayout>
<Box p="xl" className={classes.containerBox}> <Container size={420} className={classes.container}>
<Title order={2} ta="center" fw={500} mb="md"> <Box p="xl" className={classes.containerBox}>
{t("Verifying your email")} <Title order={2} ta="center" fw={500} mb="md">
</Title> {t("Verifying your email")}
<Text ta="center" c="dimmed"> </Title>
{t("Please wait...")} <Text ta="center" c="dimmed">
</Text> {t("Please wait...")}
</Box> </Text>
</Container> </Box>
</Container>
</AuthLayout>
); );
} }
return ( return (
<AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
@@ -103,5 +107,6 @@ export default function VerifyEmail() {
)} )}
</Box> </Box>
</Container> </Container>
</AuthLayout>
); );
} }
@@ -6,21 +6,23 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function DisablePublicSharing() { export default function DisablePublicSharing() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Group justify="space-between" wrap="nowrap" gap="xl"> <Group justify="space-between" wrap="nowrap" gap="xl">
<div> <div>
<Text size="md">{t("Disable public sharing")}</Text> <Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")} {t("Prevent members from sharing pages publicly.")}
</Text> </Text>
</div> </div>
<DisablePublicSharingToggle /> <DisablePublicSharingToggle />
</Group> </Group>
); );
} }
@@ -31,7 +33,8 @@ function DisablePublicSharingToggle() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true, workspace?.settings?.sharing?.disabled === true,
); );
const hasAccess = useEnterpriseAccess(); const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const applyChange = async (value: boolean) => { const applyChange = async (value: boolean) => {
try { try {
@@ -72,15 +75,11 @@ function DisablePublicSharingToggle() {
}; };
return ( return (
<Tooltip <Tooltip label={upgradeLabel} disabled={hasSharingControls} refProp="rootRef">
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch <Switch
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasAccess} disabled={!hasSharingControls}
aria-label={t("Toggle public sharing")} aria-label={t("Toggle public sharing")}
/> />
</Tooltip> </Tooltip>
@@ -1,10 +1,20 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core"; import {
Group,
Text,
Switch,
MantineSize,
Title,
Tooltip,
} from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnforceMfa() { export default function EnforceMfa() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,6 +43,8 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceMfa); const [checked, setChecked] = useState(workspace?.enforceMfa);
const hasAccess = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -49,13 +61,16 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
}; };
return ( return (
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
size={size} <Switch
label={label} size={size}
labelPosition="left" label={label}
defaultChecked={checked} labelPosition="left"
onChange={handleChange} defaultChecked={checked}
aria-label={t("Toggle MFA enforcement")} onChange={handleChange}
/> disabled={!hasAccess}
aria-label={t("Toggle MFA enforcement")}
/>
</Tooltip>
); );
} }
@@ -1,10 +1,13 @@
import { Group, Text, Switch, MantineSize } from "@mantine/core"; import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnforceSso() { export default function EnforceSso() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,6 +36,8 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceSso); const [checked, setChecked] = useState(workspace?.enforceSso);
const hasAccess = useHasFeature(Feature.SSO_CUSTOM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -49,13 +54,16 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
}; };
return ( return (
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
size={size} <Switch
label={label} size={size}
labelPosition="left" label={label}
defaultChecked={checked} labelPosition="left"
onChange={handleChange} defaultChecked={checked}
aria-label={t("Toggle sso enforcement")} onChange={handleChange}
/> disabled={!hasAccess}
aria-label={t("Toggle sso enforcement")}
/>
</Tooltip>
); );
} }
@@ -6,6 +6,9 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts"; import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.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 SpacePublicSharingToggleProps = { type SpacePublicSharingToggleProps = {
space: ISpace; space: ISpace;
@@ -17,6 +20,9 @@ export default function SpacePublicSharingToggle({
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true; const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasSharingControls || workspaceDisabled;
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true, space.settings?.sharing?.disabled === true,
); );
@@ -68,14 +74,14 @@ export default function SpacePublicSharingToggle({
</Text> </Text>
</div> </div>
<Tooltip <Tooltip
label={t("Public sharing is disabled at the workspace level")} label={!hasSharingControls ? upgradeLabel : t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled} disabled={!isDisabled}
refProp="rootRef" refProp="rootRef"
> >
<Switch <Switch
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={workspaceDisabled} disabled={isDisabled}
aria-label={t("Toggle space public sharing")} aria-label={t("Toggle space public sharing")}
/> />
</Tooltip> </Tooltip>
@@ -0,0 +1,61 @@
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 SpaceViewerCommentsToggleProps = {
space: ISpace;
};
export default function SpaceViewerCommentsToggle({
space,
}: SpaceViewerCommentsToggleProps) {
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
allowViewerComments: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow viewers to comment")}</Text>
<Text size="sm" c="dimmed">
{t("Allow viewers to add comments on pages in this space.")}
</Text>
</div>
<Tooltip
label={upgradeLabel}
disabled={!isDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
</Group>
);
}
@@ -12,13 +12,18 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type RetentionUnit = "days" | "months" | "years"; type RetentionUnit = "days" | "months" | "years";
const DEFAULT_RETENTION_DAYS = 30; const DEFAULT_RETENTION_DAYS = 30;
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } { function daysToRetention(days: number): {
amount: number;
unit: RetentionUnit;
} {
if (days >= 365 && days % 365 === 0) { if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" }; return { amount: days / 365, unit: "years" };
} }
@@ -36,14 +41,19 @@ function retentionToDays(amount: number, unit: RetentionUnit): number {
export default function TrashRetention() { export default function TrashRetention() {
const { t } = useTranslation(); const { t } = useTranslation();
const hasAccess = useEnterpriseAccess(); const hasRetention = useHasFeature(Feature.RETENTION);
const upgradeLabel = useUpgradeLabel();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS; const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const parsed = daysToRetention(currentDays); const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount); const [retentionAmount, setRetentionAmount] = useState<number | string>(
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit); parsed.amount,
);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(
parsed.unit,
);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -63,14 +73,17 @@ export default function TrashRetention() {
setSaving(true); setSaving(true);
try { try {
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days }); const updatedWorkspace = await updateWorkspace({
trashRetentionDays: days,
});
setWorkspace(updatedWorkspace); setWorkspace(updatedWorkspace);
notifications.show({ notifications.show({
message: t("Trash retention updated"), message: t("Trash retention updated"),
}); });
} catch (err: any) { } catch (err: any) {
notifications.show({ notifications.show({
message: err?.response?.data?.message || t("Failed to update trash retention"), message:
err?.response?.data?.message || t("Failed to update trash retention"),
color: "red", color: "red",
}); });
const { amount, unit } = daysToRetention(currentDays); const { amount, unit } = daysToRetention(currentDays);
@@ -81,10 +94,11 @@ export default function TrashRetention() {
} }
}; };
const isDirty = retentionToDays( const isDirty =
typeof retentionAmount === "number" ? retentionAmount : 1, retentionToDays(
retentionUnit, typeof retentionAmount === "number" ? retentionAmount : 1,
) !== currentDays; retentionUnit,
) !== currentDays;
return ( return (
<div> <div>
@@ -93,10 +107,7 @@ export default function TrashRetention() {
{t("Pages in trash will be permanently deleted after this period.")} {t("Pages in trash will be permanently deleted after this period.")}
</Text> </Text>
<Tooltip <Tooltip label={upgradeLabel} disabled={hasRetention}>
label={t("Requires an enterprise license")}
disabled={hasAccess}
>
<Group gap="xs" wrap="nowrap" maw={320}> <Group gap="xs" wrap="nowrap" maw={320}>
<NumberInput <NumberInput
value={retentionAmount} value={retentionAmount}
@@ -105,7 +116,7 @@ export default function TrashRetention() {
hideControls hideControls
size="sm" size="sm"
w={60} w={60}
disabled={!hasAccess} disabled={!hasRetention}
/> />
<Select <Select
data={[ data={[
@@ -121,13 +132,13 @@ export default function TrashRetention() {
}} }}
size="sm" size="sm"
style={{ flex: 1 }} style={{ flex: 1 }}
disabled={!hasAccess} disabled={!hasRetention}
/> />
<Button <Button
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
loading={saving} loading={saving}
disabled={!hasAccess || !isDirty} disabled={!hasRetention || !isDirty}
> >
{t("Save")} {t("Save")}
</Button> </Button>
+13 -24
View File
@@ -12,14 +12,15 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx"; import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { Feature } from "@/ee/features";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasEnterpriseAccess = useEnterpriseAccess(); const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const isCloudEE = useIsCloudEE(); const hasRetention = useHasFeature(Feature.RETENTION);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -36,39 +37,27 @@ export default function Security() {
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && ( <DisablePublicSharing />
<> <Divider my="lg" />
<DisablePublicSharing />
<Divider my="lg" />
</>
)}
{!isCloud() && ( <TrashRetention />
<> <Divider my="lg" />
<TrashRetention />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg"> <Title order={4} my="lg">
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{hasEnterpriseAccess && ( <EnforceSso />
<> <Divider my="lg" />
<EnforceSso />
<Divider my="lg" />
</>
)}
{isCloudEE && ( {(isCloud() || hasCustomSso) && (
<> <>
<AllowedDomains /> <AllowedDomains />
<Divider my="lg" /> <Divider my="lg" />
</> </>
)} )}
{hasEnterpriseAccess && ( {hasCustomSso && (
<> <>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
@@ -0,0 +1,26 @@
import React from "react";
import { Group, Text } from "@mantine/core";
import classes from "./auth.module.css";
type AuthLayoutProps = {
children: React.ReactNode;
};
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<>
<Group justify="center" gap={8} className={classes.logo}>
<img
src="/icons/favicon-32x32.png"
alt="Docmost"
width={22}
height={22}
/>
<Text size="28px" fw={700} style={{ userSelect: "none" }}>
Docmost
</Text>
</Group>
{children}
</>
);
}
@@ -1,12 +1,20 @@
.logo {
margin-top: 80px;
@media (max-width: $mantine-breakpoint-sm) {
margin-top: 30px;
}
}
.container { .container {
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px; box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
border-radius: 4px; border-radius: 4px;
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1)); background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
margin-top: 150px; margin-top: 40px;
margin-bottom: 20px; margin-bottom: 20px;
@media (max-width: $mantine-breakpoint-sm) { @media (max-width: $mantine-breakpoint-sm) {
margin-top: 50px; margin-top: 20px;
margin-bottom: 20px; margin-bottom: 20px;
} }
} }
@@ -7,6 +7,7 @@ import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
import classes from "./auth.module.css"; import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthLayout } from "./auth-layout.tsx";
const formSchema = z.object({ const formSchema = z.object({
email: z email: z
@@ -35,6 +36,7 @@ export function ForgotPasswordForm() {
} }
return ( return (
<AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
@@ -69,5 +71,6 @@ export function ForgotPasswordForm() {
</form> </form>
</Box> </Box>
</Container> </Container>
</AuthLayout>
); );
} }
@@ -19,6 +19,7 @@ import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-qu
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx"; import SsoLogin from "@/ee/components/sso-login.tsx";
import { AuthLayout } from "./auth-layout.tsx";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().trim().min(1), name: z.string().trim().min(1),
@@ -66,6 +67,7 @@ export function InviteSignUpForm() {
} }
return ( return (
<AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
@@ -111,5 +113,6 @@ export function InviteSignUpForm() {
)} )}
</Box> </Box>
</Container> </Container>
</AuthLayout>
); );
} }
@@ -21,6 +21,7 @@ import SsoLogin from "@/ee/components/sso-login.tsx";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import React from "react"; import React from "react";
import { AuthLayout } from "./auth-layout.tsx";
const formSchema = z.object({ const formSchema = z.object({
email: z email: z
@@ -62,52 +63,54 @@ export function LoginForm() {
} }
return ( return (
<Container size={420} className={classes.container}> <AuthLayout>
<Box p="xl" className={classes.containerBox}> <Container size={420} className={classes.container}>
<Title order={2} ta="center" fw={500} mb="md"> <Box p="xl" className={classes.containerBox}>
{t("Login")} <Title order={2} ta="center" fw={500} mb="md">
</Title> {t("Login")}
</Title>
<SsoLogin /> <SsoLogin />
{!data?.enforceSso && ( {!data?.enforceSso && (
<> <>
<form onSubmit={form.onSubmit(onSubmit)}> <form onSubmit={form.onSubmit(onSubmit)}>
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
label={t("Email")} label={t("Email")}
placeholder="email@example.com" placeholder="email@example.com"
variant="filled" variant="filled"
{...form.getInputProps("email")} {...form.getInputProps("email")}
/> />
<PasswordInput <PasswordInput
label={t("Password")} label={t("Password")}
placeholder={t("Your password")} placeholder={t("Your password")}
variant="filled" variant="filled"
mt="md" mt="md"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Anchor <Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD} to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link} component={Link}
underline="never" underline="never"
size="sm" size="sm"
> >
{t("Forgot your password?")} {t("Forgot your password?")}
</Anchor> </Anchor>
</Group> </Group>
<Button type="submit" fullWidth mt="md" loading={isLoading}> <Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign In")} {t("Sign In")}
</Button> </Button>
</form> </form>
</> </>
)} )}
</Box> </Box>
</Container> </Container>
</AuthLayout>
); );
} }
@@ -6,6 +6,7 @@ import { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
import classes from "./auth.module.css"; import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AuthLayout } from "./auth-layout.tsx";
const formSchema = z.object({ const formSchema = z.object({
newPassword: z newPassword: z
@@ -38,6 +39,7 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
} }
return ( return (
<AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
@@ -59,5 +61,6 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
</form> </form>
</Box> </Box>
</Container> </Container>
</AuthLayout>
); );
} }
@@ -19,6 +19,7 @@ import SsoCloudSignup from "@/ee/components/sso-cloud-signup.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts"; import APP_ROUTE from "@/lib/app-route.ts";
import { AuthLayout } from "./auth-layout.tsx";
const formSchema = z.object({ const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(), workspaceName: z.string().trim().max(50).optional(),
@@ -50,7 +51,7 @@ export function SetupWorkspaceForm() {
} }
return ( return (
<div> <AuthLayout>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}> <Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md"> <Title order={2} ta="center" fw={500} mb="md">
@@ -117,6 +118,6 @@ export function SetupWorkspaceForm() {
</Anchor> </Anchor>
</Text> </Text>
)} )}
</div> </AuthLayout>
); );
} }
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false); export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>(''); export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>(''); export const draftCommentIdAtom = atom<string>('');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
export type YjsSelection = {
anchor: any;
head: any;
};
export type ReadOnlyCommentData = {
yjsSelection: YjsSelection;
selectedText: string;
};
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
@@ -6,6 +6,8 @@ import {
activeCommentIdAtom, activeCommentIdAtom,
draftCommentIdAtom, draftCommentIdAtom,
showCommentPopupAtom, showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor"; import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions"; import CommentActions from "@/features/comment/components/comment-actions";
@@ -19,12 +21,15 @@ import { useTranslation } from "react-i18next";
interface CommentDialogProps { interface CommentDialogProps {
editor: ReturnType<typeof useEditor>; editor: ReturnType<typeof useEditor>;
pageId: string; pageId: string;
readOnly?: boolean;
} }
function CommentDialog({ editor, pageId }: CommentDialogProps) { function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom); const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
@@ -34,11 +39,17 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
handleDialogClose(); handleDialogClose();
}); });
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation; const isPending = createCommentMutation.isPending;
const handleDialogClose = () => { const handleDialogClose = () => {
setShowCommentPopup(false); if (readOnly) {
editor.chain().focus().unsetCommentDecoration().run(); setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
}
}; };
const getSelectedText = () => { const getSelectedText = () => {
@@ -47,6 +58,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
}; };
const handleAddComment = async () => { const handleAddComment = async () => {
if (readOnly) {
await handleAddReadOnlyComment();
return;
}
try { try {
const selectedText = getSelectedText(); const selectedText = getSelectedText();
const commentData = { const commentData = {
@@ -65,7 +81,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run(); .run();
setActiveCommentId(createdComment.id); setActiveCommentId(createdComment.id);
//unselect text to close bubble menu
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from }); editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true }); setAsideState({ tab: "comments", isAsideOpen: true });
@@ -85,6 +100,33 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
} }
}; };
const handleAddReadOnlyComment = async () => {
if (!readOnlyCommentData) return;
try {
const createdComment = await createCommentMutation.mutateAsync({
pageId,
content: JSON.stringify(comment),
selection: readOnlyCommentData.selectedText,
type: "inline",
yjsSelection: readOnlyCommentData.yjsSelection,
});
setActiveCommentId(createdComment.id);
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 400);
} finally {
setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
}
};
const handleCommentEditorChange = (newContent: any) => { const handleCommentEditorChange = (newContent: any) => {
setComment(newContent); setComment(newContent);
}; };
@@ -7,7 +7,8 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions"; import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu"; import CommentMenu from "@/features/comment/components/comment-menu";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import ResolveComment from "@/ee/comment/components/resolve-comment"; import ResolveComment from "@/ee/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { import {
@@ -44,7 +45,7 @@ function CommentListItem({
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const isCloudEE = useIsCloudEE(); const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const createdAtAgo = useTimeAgo(comment.createdAt); const createdAtAgo = useTimeAgo(comment.createdAt);
useEffect(() => { useEffect(() => {
@@ -81,7 +82,7 @@ function CommentListItem({
} }
async function handleResolveComment() { async function handleResolveComment() {
if (!isCloudEE) return; if (!canResolve) return;
try { try {
const isResolved = comment.resolvedAt != null; const isResolved = comment.resolvedAt != null;
@@ -137,7 +138,7 @@ function CommentListItem({
</Text> </Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}> <div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && isCloudEE && ( {!comment.parentCommentId && canComment && canResolve && (
<ResolveComment <ResolveComment
editor={editor} editor={editor}
commentId={comment.id} commentId={comment.id}
@@ -44,7 +44,9 @@ function CommentListWithTabs() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canComment = page?.permissions?.canEdit ?? false; const canComment =
(page?.permissions?.canEdit ?? false) ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments // Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => { const { activeComments, resolvedComments } = useMemo(() => {
@@ -153,7 +155,7 @@ function CommentListWithTabs() {
)} )}
</Paper> </Paper>
), ),
[comments, handleAddReply, isLoading, space?.membership?.role], [comments, handleAddReply, isLoading, space?.membership?.role, canComment],
); );
if (isCommentsLoading) { if (isCommentsLoading) {
@@ -1,8 +1,16 @@
import { ActionIcon, Menu, Tooltip } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react"; import {
IconDots,
IconEdit,
IconTrash,
IconCircleCheck,
IconCircleCheckFilled,
} from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type CommentMenuProps = { type CommentMenuProps = {
onEditComment: () => void; onEditComment: () => void;
@@ -13,16 +21,17 @@ type CommentMenuProps = {
isParentComment?: boolean; isParentComment?: boolean;
}; };
function CommentMenu({ function CommentMenu({
onEditComment, onEditComment,
onDeleteComment, onDeleteComment,
onResolveComment, onResolveComment,
canEdit = true, canEdit = true,
isResolved = false, isResolved = false,
isParentComment = false isParentComment = false,
}: CommentMenuProps) { }: CommentMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCloudEE = useIsCloudEE(); const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const upgradeLabel = useUpgradeLabel();
//@ts-ignore //@ts-ignore
const openDeleteModal = () => const openDeleteModal = () =>
@@ -44,33 +53,34 @@ function CommentMenu({
<Menu.Dropdown> <Menu.Dropdown>
{canEdit && ( {canEdit && (
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}> <Menu.Item
onClick={onEditComment}
leftSection={<IconEdit size={14} />}
>
{t("Edit comment")} {t("Edit comment")}
</Menu.Item> </Menu.Item>
)} )}
{isParentComment && ( {isParentComment &&
isCloudEE ? ( (canResolve ? (
<Menu.Item <Menu.Item
onClick={onResolveComment} onClick={onResolveComment}
leftSection={ leftSection={
isResolved ? isResolved ? (
<IconCircleCheckFilled size={14} /> : <IconCircleCheckFilled size={14} />
) : (
<IconCircleCheck size={14} /> <IconCircleCheck size={14} />
)
} }
> >
{isResolved ? t("Re-open comment") : t("Resolve comment")} {isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item> </Menu.Item>
) : ( ) : (
<Tooltip label={t("Available in enterprise edition")} position="left"> <Tooltip label={upgradeLabel} position="left" withPortal={false}>
<Menu.Item <Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
disabled
leftSection={<IconCircleCheck size={14} />}
>
{t("Resolve comment")} {t("Resolve comment")}
</Menu.Item> </Menu.Item>
</Tooltip> </Tooltip>
) ))}
)}
<Menu.Item <Menu.Item
leftSection={<IconTrash size={14} />} leftSection={<IconTrash size={14} />}
onClick={openDeleteModal} onClick={openDeleteModal}
@@ -17,6 +17,10 @@ export interface IComment {
deletedAt?: Date; deletedAt?: Date;
creator: IUser; creator: IUser;
resolvedBy?: IUser; resolvedBy?: IUser;
yjsSelection?: {
anchor: any;
head: any;
};
} }
export interface ICommentData { export interface ICommentData {
@@ -10,3 +10,5 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>(""); export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false); export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
@@ -26,7 +26,7 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms"; import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem { export interface BubbleMenuItem {
@@ -49,6 +49,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup); const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu); const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
useEffect(() => { useEffect(() => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
@@ -58,6 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showAiMenuRef.current = showAiMenu; showAiMenuRef.current = showAiMenu;
}, [showAiMenu]); }, [showAiMenu]);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]);
const editorState = useEditorState({ const editorState = useEditorState({
editor: props.editor, editor: props.editor,
selector: (ctx) => { selector: (ctx) => {
@@ -135,6 +141,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
isNodeSelection(selection) || isNodeSelection(selection) ||
isCellSelection(selection) || isCellSelection(selection) ||
showAiMenuRef.current || showAiMenuRef.current ||
showLinkMenuRef.current ||
showCommentPopupRef?.current showCommentPopupRef?.current
) { ) {
return false; return false;
@@ -147,7 +154,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
}, },
}, },
@@ -155,11 +161,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown // Hide the bubble menu immediately when AI menu is shown
if (showAiMenu) return; if (showAiMenu || showLinkMenu) return;
return ( return (
<BubbleMenu <BubbleMenu
@@ -189,7 +194,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
}} }}
/> />
@@ -200,7 +204,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
}} }}
/> />
@@ -224,16 +227,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
))} ))}
</ActionIcon.Group> </ActionIcon.Group>
<LinkSelector <LinkSelector />
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={(value) => {
setIsLinkSelectorOpen(value);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<ColorSelector <ColorSelector
editor={props.editor} editor={props.editor}
@@ -242,7 +236,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsColorSelectorOpen(!isColorSelectorOpen); setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -1,68 +1,25 @@
import { Dispatch, FC, SetStateAction, useCallback } from "react"; import { FC } from "react";
import { IconLink } from "@tabler/icons-react"; import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Popover, Tooltip } from "@mantine/core"; import { ActionIcon, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import { useSetAtom } from "jotai";
import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { normalizeUrl } from "@/features/editor/components/link/link-view";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
interface LinkSelectorProps { export const LinkSelector: FC = () => {
editor: ReturnType<typeof useEditor>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const onLink = useCallback( const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
(url: string, internal?: boolean) => {
setIsOpen(false);
editor
.chain()
.focus()
.setLink({ href: internal ? url : normalizeUrl(url), internal: !!internal } as any)
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
},
[editor, setIsOpen],
);
return ( return (
<Popover <Tooltip label={t("Add link")} withArrow>
width={320} <ActionIcon
opened={isOpen} variant="default"
trapFocus size="lg"
offset={{ mainAxis: 35, crossAxis: 0 }} radius="0"
withArrow style={{ border: "none" }}
shadow="md" onClick={() => setShowLinkMenu(true)}
> >
<Popover.Target> <IconLink size={16} />
<Tooltip label={t("Add link")} withArrow> </ActionIcon>
<ActionIcon </Tooltip>
variant="default"
size="lg"
radius="0"
style={{
border: "none",
}}
onClick={() => setIsOpen(!isOpen)}
>
<IconLink size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown p="sm">
<LinkEditorPanel onSetLink={onLink} />
</Popover.Dropdown>
</Popover>
); );
}; };
@@ -0,0 +1,159 @@
import type { Editor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import {
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom";
import { useTranslation } from "react-i18next";
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
type ReadonlyBubbleMenuProps = {
editor: Editor;
};
export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
const { t } = useTranslation();
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
showReadOnlyCommentPopupAtom,
);
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const menuRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const isInteractingRef = useRef(false);
const updateMenuPosition = useCallback(() => {
if (isInteractingRef.current) return;
const pmSelection = editor.state.selection;
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
setVisible(false);
return;
}
const selection = window.getSelection();
if (
!selection ||
selection.isCollapsed ||
selection.rangeCount === 0 ||
showReadOnlyCommentPopup
) {
setVisible(false);
return;
}
const editorDom = editor.view.dom;
if (
!editorDom.contains(selection.anchorNode) ||
!editorDom.contains(selection.focusNode)
) {
setVisible(false);
return;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width === 0) {
setVisible(false);
return;
}
const editorRect = editorDom
.closest(".editor-container")
?.getBoundingClientRect();
if (!editorRect) {
setVisible(false);
return;
}
setPosition({
top: rect.top - editorRect.top - 44,
left: rect.left - editorRect.left + rect.width / 2,
});
setVisible(true);
}, [editor, showReadOnlyCommentPopup]);
useEffect(() => {
const handleSelectionChange = () => {
updateMenuPosition();
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
};
}, [updateMenuPosition]);
useEffect(() => {
if (showReadOnlyCommentPopup) {
setVisible(false);
}
}, [showReadOnlyCommentPopup]);
const handleCommentClick = () => {
if (!editor) return;
const view = editor.view;
const ystate = ySyncPluginKey.getState(view.state);
if (ystate?.binding) {
const selection = getRelativeSelection(ystate.binding, view.state);
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to);
// @ts-ignore
setReadOnlyCommentData({
yjsSelection: {
anchor: selection.anchor,
head: selection.head,
},
selectedText,
});
setShowReadOnlyCommentPopup(true);
setVisible(false);
}
};
if (!visible) return null;
return (
<div
ref={menuRef}
style={{
position: "absolute",
top: position.top,
left: position.left,
transform: "translateX(-50%)",
zIndex: 199,
}}
>
<div className={classes.bubbleMenu}>
<Tooltip label={t("Comment")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t("Comment")}
style={{ border: "none" }}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
isInteractingRef.current = true;
handleCommentClick();
isInteractingRef.current = false;
}}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</div>
);
};
@@ -8,6 +8,7 @@ import {
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { import {
ActionIcon, ActionIcon,
LoadingOverlay,
Modal, Modal,
Text, Text,
Tooltip, Tooltip,
@@ -46,6 +47,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false); const isDirtyRef = useRef(false);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -140,6 +143,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (isSavingRef.current) return; if (isSavingRef.current) return;
isSavingRef.current = true; isSavingRef.current = true;
setIsSaving(true);
try { try {
const svgString = decodeBase64ToSvgString(svgXml); const svgString = decodeBase64ToSvgString(svgXml);
@@ -167,6 +171,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
isDirtyRef.current = false; isDirtyRef.current = false;
} finally { } finally {
isSavingRef.current = false; isSavingRef.current = false;
setIsSaving(false);
} }
}, [editor, editorState?.attachmentId]); }, [editor, editorState?.attachmentId]);
@@ -196,6 +201,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const handleOpen = useCallback(async () => { const handleOpen = useCallback(async () => {
if (!editorState?.src) return; if (!editorState?.src) return;
setIsLoading(true);
try { try {
const url = getFileUrl(editorState.src); const url = getFileUrl(editorState.src);
const request = await fetch(url, { const request = await fetch(url, {
@@ -213,6 +219,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
setIsLoading(false);
isDirtyRef.current = false; isDirtyRef.current = false;
open(); open();
} }
@@ -307,6 +314,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
size="lg" size="lg"
aria-label={t("Edit")} aria-label={t("Edit")}
variant="subtle" variant="subtle"
loading={isLoading}
> >
<IconEdit size={18} /> <IconEdit size={18} />
</ActionIcon> </ActionIcon>
@@ -339,7 +347,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body pos="relative">
<LoadingOverlay visible={isSaving} />
<div style={{ height: "100vh" }}> <div style={{ height: "100vh" }}>
<DrawIoEmbed <DrawIoEmbed
ref={drawioRef} ref={drawioRef}
@@ -2,6 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { import {
ActionIcon, ActionIcon,
Card, Card,
LoadingOverlay,
Modal, Modal,
Text, Text,
useComputedColorScheme, useComputedColorScheme,
@@ -34,6 +35,7 @@ export default function DrawioView(props: NodeViewProps) {
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false); const isDirtyRef = useRef(false);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
@@ -47,6 +49,7 @@ export default function DrawioView(props: NodeViewProps) {
if (isSavingRef.current) return; if (isSavingRef.current) return;
isSavingRef.current = true; isSavingRef.current = true;
setIsSaving(true);
try { try {
const svgString = decodeBase64ToSvgString(svgXml); const svgString = decodeBase64ToSvgString(svgXml);
@@ -79,6 +82,7 @@ export default function DrawioView(props: NodeViewProps) {
isDirtyRef.current = false; isDirtyRef.current = false;
} finally { } finally {
isSavingRef.current = false; isSavingRef.current = false;
setIsSaving(false);
} }
}; };
@@ -136,7 +140,8 @@ export default function DrawioView(props: NodeViewProps) {
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body pos="relative">
<LoadingOverlay visible={isSaving} />
<div style={{ height: "100vh" }}> <div style={{ height: "100vh" }}>
<DrawIoEmbed <DrawIoEmbed
ref={drawioRef} ref={drawioRef}
@@ -56,6 +56,8 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false); const isDirtyRef = useRef(false);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isInitialLoadRef = useRef(true); const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef(""); const lastFingerprintRef = useRef("");
@@ -153,6 +155,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const handleOpen = useCallback(async () => { const handleOpen = useCallback(async () => {
if (!editorState?.src) return; if (!editorState?.src) return;
setIsLoading(true);
try { try {
const url = getFileUrl(editorState.src); const url = getFileUrl(editorState.src);
const request = await fetch(url, { const request = await fetch(url, {
@@ -166,6 +169,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
setIsLoading(false);
isDirtyRef.current = false; isDirtyRef.current = false;
isInitialLoadRef.current = true; isInitialLoadRef.current = true;
open(); open();
@@ -178,6 +182,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} }
isSavingRef.current = true; isSavingRef.current = true;
setIsSaving(true);
try { try {
const { exportToSvg } = await import("@excalidraw/excalidraw"); const { exportToSvg } = await import("@excalidraw/excalidraw");
@@ -223,6 +228,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
isDirtyRef.current = false; isDirtyRef.current = false;
} finally { } finally {
isSavingRef.current = false; isSavingRef.current = false;
setIsSaving(false);
} }
}, [editor, excalidrawAPI, editorState?.attachmentId]); }, [editor, excalidrawAPI, editorState?.attachmentId]);
@@ -339,6 +345,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
size="lg" size="lg"
aria-label={t("Edit")} aria-label={t("Edit")}
variant="subtle" variant="subtle"
loading={isLoading}
> >
<IconEdit size={18} /> <IconEdit size={18} />
</ActionIcon> </ActionIcon>
@@ -390,7 +397,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSaveAndExit} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
@@ -52,6 +52,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
const isDirtyRef = useRef(false); const isDirtyRef = useRef(false);
const isSavingRef = useRef(false); const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const isInitialLoadRef = useRef(true); const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef(""); const lastFingerprintRef = useRef("");
@@ -70,6 +71,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
} }
isSavingRef.current = true; isSavingRef.current = true;
setIsSaving(true);
try { try {
const { exportToSvg } = await import("@excalidraw/excalidraw"); const { exportToSvg } = await import("@excalidraw/excalidraw");
@@ -120,6 +122,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
isDirtyRef.current = false; isDirtyRef.current = false;
} finally { } finally {
isSavingRef.current = false; isSavingRef.current = false;
setIsSaving(false);
} }
}, [excalidrawAPI, editor, attachmentId, updateAttributes]); }, [excalidrawAPI, editor, attachmentId, updateAttributes]);
@@ -191,7 +194,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSaveAndExit} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
@@ -36,7 +36,7 @@ export const LinkEditorPanel = ({
includeUsers: false, includeUsers: false,
includePages: true, includePages: true,
spaceId: space?.id, spaceId: space?.id,
limit: state.isSearchQuery ? 10 : 5, limit: state.isSearchQuery ? 10 : 3,
preload: true, preload: true,
}); });
@@ -105,6 +105,7 @@ export const LinkEditorPanel = ({
value={state.url} value={state.url}
onChange={state.onChange} onChange={state.onChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
data-autofocus
autoFocus autoFocus
/> />
</form> </form>
@@ -0,0 +1,114 @@
import { FC, useCallback, useEffect, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import type { Editor } from "@tiptap/react";
import { useAtom } from "jotai";
import { isTextSelected } from "@docmost/editor-ext";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel";
import { normalizeUrl } from "@/lib/utils";
import { TextSelection } from "@tiptap/pm/state";
import { Paper } from "@mantine/core";
type EditorLinkMenuProps = {
editor: Editor;
};
export const EditorLinkMenu: FC<EditorLinkMenuProps> = ({ editor }) => {
const [showLinkMenu, setShowLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
if (showLinkMenu) {
editor.commands.focus();
}
}, [showLinkMenu, editor]);
const focusInput = useCallback(() => {
requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLInputElement>("input")
?.focus({ preventScroll: true });
});
}, []);
const onSetLink = useCallback(
(url: string, internal?: boolean) => {
editor
.chain()
.focus()
.setLink({
href: internal ? url : normalizeUrl(url),
internal: !!internal,
} as any)
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowLinkMenu(false);
},
[editor, setShowLinkMenu],
);
useEffect(() => {
if (!showLinkMenu) return;
const dismiss = () => {
setShowLinkMenu(false);
editor.commands.focus();
editor.commands.setTextSelection(editor.state.selection.to);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
dismiss();
}
};
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
dismiss();
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [showLinkMenu, setShowLinkMenu]);
if (!showLinkMenu) return null;
return (
<BubbleMenu
editor={editor}
shouldShow={({ editor, state }) => {
const { empty } = state.selection;
return (
showLinkMenuRef.current &&
editor.isEditable &&
!empty &&
isTextSelected(editor)
);
}}
options={{
placement: "bottom",
offset: 8,
onShow: focusInput,
onHide: () => {
setShowLinkMenu(false);
},
}}
style={{ zIndex: 198, position: "relative" }}
>
<Paper ref={containerRef} w={320} p="sm" shadow="md" radius={6} withBorder>
<LinkEditorPanel onSetLink={onSetLink} />
</Paper>
</BubbleMenu>
);
};
@@ -29,12 +29,7 @@ import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext"; import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
export const normalizeUrl = (url: string): string => {
if (!url) return url;
if (url.startsWith("/") || /^(\S+):(\/\/)?\S+$/.test(url)) return url;
return `https://${url}`;
};
const parseInternalLink = ( const parseInternalLink = (
href: string, href: string,
@@ -16,6 +16,7 @@ export interface FullEditorProps {
content: string; content: string;
spaceSlug: string; spaceSlug: string;
editable: boolean; editable: boolean;
canComment?: boolean;
} }
export function FullEditor({ export function FullEditor({
@@ -25,6 +26,7 @@ export function FullEditor({
content, content,
spaceSlug, spaceSlug,
editable, editable,
canComment,
}: FullEditorProps) { }: FullEditorProps) {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth; const fullPageWidth = user.settings?.preferences?.fullPageWidth;
@@ -46,6 +48,7 @@ export function FullEditor({
pageId={pageId} pageId={pageId}
editable={editable} editable={editable}
content={content} content={content}
canComment={canComment}
/> />
</Container> </Container>
); );
@@ -37,9 +37,11 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import { import {
activeCommentIdAtom, activeCommentIdAtom,
showCommentPopupAtom, showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import CommentDialog from "@/features/comment/components/comment-dialog"; import CommentDialog from "@/features/comment/components/comment-dialog";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu"; import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx"; import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx"; import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx"; import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
@@ -66,18 +68,21 @@ import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu"; import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
editable: boolean; editable: boolean;
content: any; content: any;
canComment?: boolean;
} }
export default function PageEditor({ export default function PageEditor({
pageId, pageId,
editable, editable,
content, content,
canComment,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
@@ -92,6 +97,7 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false); const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -407,6 +413,7 @@ export default function PageEditor({
{editor && editorIsEditable && ( {editor && editorIsEditable && (
<div> <div>
<EditorAiMenu editor={editor} /> <EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} /> <TableCellMenu editor={editor} appendTo={menuContainerRef} />
@@ -419,7 +426,13 @@ export default function PageEditor({
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
</div> </div>
)} )}
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
<ReadonlyBubbleMenu editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
</div> </div>
<div <div
onClick={() => editor.commands.focus("end")} onClick={() => editor.commands.focus("end")}
@@ -12,7 +12,7 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types"; import { INotification } from "../types/notification.types";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query"; import { useMarkReadMutation } from "../queries/notification-query";
@@ -36,20 +36,20 @@ export function NotificationItem({
const isUnread = !notification.readAt; const isUnread = !notification.readAt;
const getNotificationMessage = (): string => { const getNotificationMessageKey = (): string => {
switch (notification.type) { switch (notification.type) {
case "comment.user_mention": case "comment.user_mention":
return t("mentioned you in a comment"); return "<bold>{{name}}</bold> mentioned you in a comment";
case "comment.created": case "comment.created":
return t("commented on a page"); return "<bold>{{name}}</bold> commented on a page";
case "comment.resolved": case "comment.resolved":
return t("resolved a comment"); return "<bold>{{name}}</bold> resolved a comment";
case "page.user_mention": case "page.user_mention":
return t("mentioned you on a page"); return "<bold>{{name}}</bold> mentioned you on a page";
case "page.permission_granted": case "page.permission_granted":
return notification.data?.role === "writer" return notification.data?.role === "writer"
? t("gave you edit access to a page") ? "<bold>{{name}}</bold> gave you edit access to a page"
: t("gave you view access to a page"); : "<bold>{{name}}</bold> gave you view access to a page";
default: default:
return ""; return "";
} }
@@ -95,10 +95,11 @@ export function NotificationItem({
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={2}> <Text size="sm" lineClamp={2}>
<Text span fw={600}> <Trans
{notification.actor?.name} i18nKey={getNotificationMessageKey()}
</Text>{" "} values={{ name: notification.actor?.name }}
{getNotificationMessage()} components={{ bold: <Text span fw={600} /> }}
/>
</Text> </Text>
{notification.page && ( {notification.page && (
@@ -28,9 +28,11 @@ import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx"; import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts"; import { getFileImportSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts"; import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@@ -82,7 +84,6 @@ interface ImportFormatSelection {
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const { t } = useTranslation(); const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const [workspace] = useAtom(workspaceAtom);
const [fileTaskId, setFileTaskId] = useState<string | null>(null); const [fileTaskId, setFileTaskId] = useState<string | null>(null);
const emit = useQueryEmit(); const emit = useQueryEmit();
@@ -93,8 +94,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const confluenceFileRef = useRef<() => void>(null); const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null); const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
const canUseDocx = isCloud() || workspace?.hasLicenseKey; const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
const upgradeLabel = useUpgradeLabel();
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) { if (!selectedFile) {
@@ -360,7 +362,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseDocx} disabled={canUseDocx}
> >
<Button <Button
@@ -399,7 +401,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseConfluence} disabled={canUseConfluence}
> >
<Button <Button
@@ -110,15 +110,7 @@ export function useUpdatePageMutation() {
return useMutation<IPage, Error, Partial<IPageInput>>({ return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => updatePage(data), mutationFn: (data) => updatePage(data),
onSuccess: (data) => { onSuccess: (data) => {
updatePage(data); updatePageData(data);
invalidateOnUpdatePage(
data.spaceId,
data.parentPageId,
data.id,
data.title,
data.icon,
);
}, },
}); });
} }
@@ -22,9 +22,9 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
@@ -42,7 +42,7 @@ export function SearchSpotlightFilters({
isAiMode = false, isAiMode = false,
}: SearchSpotlightFiltersProps) { }: SearchSpotlightFiltersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>( const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null, spaceId || null,
); );
@@ -87,7 +87,7 @@ export function SearchSpotlightFilters({
{ {
value: "attachment", value: "attachment",
label: t("Attachments"), label: t("Attachments"),
disabled: !isCloud() && !hasLicenseKey, disabled: !hasAttachmentIndexing,
}, },
]; ];
@@ -11,15 +11,16 @@ import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts"; import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx"; import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx"; import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { isCloud } from "@/lib/config.ts"; import { Feature } from "@/ee/features";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
} }
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300); const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
@@ -84,7 +85,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && (hasLicenseKey || isCloud()); filters.contentType === "attachment" && hasAttachmentIndexing;
const resultItems = (searchResults || []).map((result) => ( const resultItems = (searchResults || []).map((result) => (
<SearchResultItem <SearchResultItem
@@ -134,7 +135,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
} }
}} }}
/> />
{isAiMode && hasLicenseKey && ( {isAiMode && hasAiFeature && (
<Button <Button
size="xs" size="xs"
leftSection={<IconSparkles size={16} />} leftSection={<IconSparkles size={16} />}
@@ -8,8 +8,8 @@ import {
IPageSearch, IPageSearch,
IPageSearchParams, IPageSearchParams,
} from "@/features/search/types/search.types"; } from "@/features/search/types/search.types";
import { useLicense } from "@/ee/hooks/use-license"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { isCloud } from "@/lib/config"; import { Feature } from "@/ee/features";
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch; export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
@@ -21,10 +21,10 @@ export function useUnifiedSearch(
params: UseUnifiedSearchParams, params: UseUnifiedSearchParams,
enabled: boolean = true, enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> { ): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const isAttachmentSearch = const isAttachmentSearch =
params.contentType === "attachment" && (isCloud() || hasLicenseKey); params.contentType === "attachment" && hasAttachmentIndexing;
const searchType = isAttachmentSearch ? "attachment" : "page"; const searchType = isAttachmentSearch ? "attachment" : "page";
return useQuery({ return useQuery({
@@ -0,0 +1,165 @@
import { useState } from "react";
import {
Button,
Divider,
Group,
Skeleton,
Stack,
Table,
Text,
} from "@mantine/core";
import { IconDevices } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useGetSessionsQuery,
useRevokeSessionMutation,
useRevokeAllSessionsMutation,
} from "@/features/session/queries/session-query";
import { formattedDate } from "@/lib/time";
const PAGE_SIZE = 5;
export default function SessionList() {
const { t } = useTranslation();
const { data: sessions, isLoading } = useGetSessionsQuery();
const revokeSessionMutation = useRevokeSessionMutation();
const revokeAllSessionsMutation = useRevokeAllSessionsMutation();
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const otherSessions = sessions?.filter((s) => !s?.isCurrentDevice) ?? [];
const visibleSessions = sessions?.slice(0, visibleCount) ?? [];
const hasMore = sessions && visibleCount < sessions.length;
if (isLoading) {
return (
<Table verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Device Name")}</Table.Th>
<Table.Th>{t("Last Active")}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[1, 2, 3].map((i) => (
<Table.Tr key={i}>
<Table.Td>
<Group gap="xs">
<Skeleton height={18} width={18} radius="sm" />
<Skeleton height={14} width={140} radius="xs" />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} radius="xs" />
</Table.Td>
<Table.Td>
<Skeleton height={30} width={70} radius="sm" />
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
return (
<Stack>
{otherSessions.length > 0 && (
<>
<div>
<Text fw={500}>{t("Log out of all devices")}</Text>
<Group justify="space-between" align="center" mt={4}>
<Text size="sm" c="dimmed">
{t(
"Log out of all sessions except this device",
)}
</Text>
<Button
variant="outline"
color="red"
size="xs"
loading={revokeAllSessionsMutation.isPending}
onClick={() => revokeAllSessionsMutation.mutate()}
>
{t("Log out of all devices")}
</Button>
</Group>
</div>
<Divider />
</>
)}
<Table verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Device Name")}</Table.Th>
<Table.Th>{t("Last Active")}</Table.Th>
{otherSessions.length > 0 && <Table.Th />}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{visibleSessions.map((session) => (
<Table.Tr key={session.id}>
<Table.Td>
<Group gap="xs">
<IconDevices size={18} stroke={1.5} />
<div>
<Text size="sm">
{session.deviceName || t("Unknown device")}
</Text>
{session?.isCurrentDevice && (
<Text size="xs" c="blue">
{t("This Device")}
</Text>
)}
</div>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">
{session?.isCurrentDevice
? t("Now")
: formattedDate(new Date(session.lastActiveAt))}
</Text>
</Table.Td>
{otherSessions.length > 0 && (
<Table.Td>
{!session?.isCurrentDevice && (
<Button
variant="outline"
size="xs"
loading={revokeSessionMutation.isPending}
onClick={() =>
revokeSessionMutation.mutate({
sessionId: session.id,
})
}
>
{t("Log out")}
</Button>
)}
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
</Table>
{hasMore && (
<Button
variant="subtle"
size="xs"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
>
{t("Load more")}
</Button>
)}
{(!sessions || sessions.length === 0) && (
<Text size="sm" c="dimmed" ta="center">
{t("No active sessions")}
</Text>
)}
</Stack>
);
}
@@ -0,0 +1,55 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getSessions,
revokeSession,
revokeAllSessions,
} from "@/features/session/services/session-service";
import { ISession } from "@/features/session/types/session.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetSessionsQuery(): UseQueryResult<ISession[], Error> {
return useQuery({
queryKey: ["session-list"],
queryFn: () => getSessions(),
});
}
export function useRevokeSessionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { sessionId: string }>({
mutationFn: (data) => revokeSession(data),
onSuccess: () => {
notifications.show({ message: t("Session revoked") });
queryClient.invalidateQueries({ queryKey: ["session-list"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeAllSessionsMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, void>({
mutationFn: () => revokeAllSessions(),
onSuccess: () => {
notifications.show({ message: t("All other sessions revoked") });
queryClient.invalidateQueries({ queryKey: ["session-list"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,17 @@
import api from "@/lib/api-client";
import { ISession } from "@/features/session/types/session.types";
export async function getSessions(): Promise<ISession[]> {
const req = await api.post<{ sessions: ISession[] }>("/sessions");
return req.data.sessions;
}
export async function revokeSession(data: {
sessionId: string;
}): Promise<void> {
await api.post("/sessions/revoke", data);
}
export async function revokeAllSessions(): Promise<void> {
await api.post("/sessions/revoke-all");
}
@@ -0,0 +1,8 @@
export type ISession = {
id: string;
deviceName: string | null;
geoLocation: string | null;
lastActiveAt: string;
createdAt: string;
isCurrentDevice?: boolean;
};
@@ -180,7 +180,7 @@ export default function ShareShell({
<AppShell.Main> <AppShell.Main>
{children} {children}
{data && shareId && !data.hasLicenseKey && <ShareBranding />} {data && shareId && !(data.features?.length > 0) && <ShareBranding />}
</AppShell.Main> </AppShell.Main>
<AppShell.Aside <AppShell.Aside
@@ -41,7 +41,7 @@ export interface ISharedPage extends IShare {
level: number; level: number;
sharedPage: { id: string; slugId: string; title: string; icon: string }; sharedPage: { id: string; slugId: string; title: string; icon: string };
}; };
hasLicenseKey: boolean; features?: string[];
} }
export interface IShareForPage extends IShare { export interface IShareForPage extends IShare {
@@ -71,5 +71,5 @@ export interface IShareInfoInput {
export interface ISharedPageTree { export interface ISharedPageTree {
share: IShare; share: IShare;
pageTree: Partial<IPage[]>; pageTree: Partial<IPage[]>;
hasLicenseKey: boolean; features?: string[];
} }
@@ -3,6 +3,7 @@ import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx"; import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React from "react"; import React from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx"; import SpaceDetails from "@/features/space/components/space-details.tsx";
import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { import {
@@ -59,6 +60,14 @@ export default function SpaceSettingsModal({
<Tabs.Tab fw={500} value="members"> <Tabs.Tab fw={500} value="members">
{t("Members")} {t("Members")}
</Tabs.Tab> </Tabs.Tab>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
) && (
<Tabs.Tab fw={500} value="security">
{t("Security")}
</Tabs.Tab>
)}
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
@@ -91,6 +100,20 @@ export default function SpaceSettingsModal({
)} )}
/> />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="security">
<ScrollArea h={580} scrollbarSize={5} pr={8}>
<div style={{ paddingBottom: "100px" }}>
<SpaceSecuritySettings
space={space}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</div>
</ScrollArea>
</Tabs.Panel>
</Tabs> </Tabs>
</div> </div>
</Modal.Body> </Modal.Body>
@@ -18,8 +18,7 @@ import {
ResponsiveSettingsControl, ResponsiveSettingsControl,
ResponsiveSettingsRow, ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx"; } from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -28,8 +27,6 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const hasEnterpriseAccess = useEnterpriseAccess();
const showSharingToggle = !readOnly && hasEnterpriseAccess;
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false); const [isIconUploading, setIsIconUploading] = useState(false);
@@ -91,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<EditSpaceForm space={space} readOnly={readOnly} /> <EditSpaceForm space={space} readOnly={readOnly} />
{showSharingToggle && (
<>
<Divider my="lg" />
<SpacePublicSharingToggle space={space} />
</>
)}
{!readOnly && ( {!readOnly && (
<> <>
<Divider my="lg" /> <Divider my="lg" />
@@ -0,0 +1,34 @@
import { Text, Divider } from "@mantine/core";
import React from "react";
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";
type SpaceSecuritySettingsProps = {
space: ISpace;
readOnly?: boolean;
};
export default function SpaceSecuritySettings({
space,
readOnly,
}: SpaceSecuritySettingsProps) {
const { t } = useTranslation();
if (readOnly) return null;
return (
<div>
<Text my="md" fw={600}>
{t("Security")}
</Text>
<SpacePublicSharingToggle space={space} />
<Divider my="lg" />
<SpaceViewerCommentsToggle space={space} />
</div>
);
}
@@ -9,8 +9,13 @@ export interface ISpaceSharingSettings {
disabled?: boolean; disabled?: boolean;
} }
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
}
export interface ISpaceSettings { export interface ISpaceSettings {
sharing?: ISpaceSharingSettings; sharing?: ISpaceSharingSettings;
comments?: ISpaceCommentsSettings;
} }
export interface ISpace { export interface ISpace {
@@ -29,6 +34,7 @@ export interface ISpace {
settings?: ISpaceSettings; settings?: ISpaceSettings;
// for updates // for updates
disablePublicSharing?: boolean; disablePublicSharing?: boolean;
allowViewerComments?: boolean;
} }
interface IMembership { interface IMembership {
@@ -1,9 +1,8 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { focusAtom } from "jotai-optics";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver"; import { zod4Resolver } from "mantine-form-zod-resolver";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts"; import { updateUser } from "@/features/user/services/user-service.ts";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import { useState } from "react"; import { useState } from "react";
@@ -17,18 +16,15 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
export default function AccountNameForm() { export default function AccountNameForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [currentUser] = useAtom(currentUserAtom); const [user, setUser] = useAtom(userAtom);
const [, setUser] = useAtom(userAtom);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {
name: currentUser?.user.name, name: user?.name,
}, },
}); });
@@ -1,4 +1,4 @@
import { useAtom } from "jotai"; import { useAtom, useSetAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user"; import useCurrentUser from "@/features/user/hooks/use-current-user";
@@ -11,10 +11,14 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts"; import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import { useEntitlements } from "@/ee/entitlement/use-entitlements";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export function UserProvider({ children }: React.PropsWithChildren) { export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom); const [, setCurrentUser] = useAtom(currentUserAtom);
const setEntitlements = useSetAtom(entitlementAtom);
const { data, isLoading, error, isError } = useCurrentUser(); const { data, isLoading, error, isError } = useCurrentUser();
const { data: entitlements } = useEntitlements();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [, setSocket] = useAtom(socketAtom); const [, setSocket] = useAtom(socketAtom);
// fetch collab token on load // fetch collab token on load
@@ -56,6 +60,12 @@ export function UserProvider({ children }: React.PropsWithChildren) {
} }
}, [data, isLoading]); }, [data, isLoading]);
useEffect(() => {
if (entitlements) {
setEntitlements(entitlements);
}
}, [entitlements]);
if (isLoading) return <></>; if (isLoading) return <></>;
if (isError && error?.["response"]?.status === 404) { if (isError && error?.["response"]?.status === 404) {
@@ -20,7 +20,6 @@ export interface IWorkspace {
emailDomains: string[]; emailDomains: string[];
memberCount?: number; memberCount?: number;
plan?: string; plan?: string;
hasLicenseKey?: boolean;
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean; aiSearch?: boolean;
generativeAi?: boolean; generativeAi?: boolean;
@@ -84,7 +83,6 @@ export interface IPublicWorkspace {
hostname: string; hostname: string;
enforceSso: boolean; enforceSso: boolean;
authProviders: IAuthProvider[]; authProviders: IAuthProvider[];
hasLicenseKey?: boolean;
} }
export interface IVersion { export interface IVersion {
@@ -1,7 +0,0 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};
+6 -2
View File
@@ -74,8 +74,12 @@ function redirectToLogin() {
]; ];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) { if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname; const redirectTo = window.location.pathname;
const params = new URLSearchParams({ redirect: redirectTo }); if (redirectTo === APP_ROUTE.HOME) {
window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`; window.location.href = APP_ROUTE.AUTH.LOGIN;
} else {
const params = new URLSearchParams({ redirect: redirectTo });
window.location.href = `${APP_ROUTE.AUTH.LOGIN}?${params.toString()}`;
}
} }
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import bytes from "bytes"; import bytes from "bytes";
import { castToBoolean } from "@/lib/utils.tsx"; import { castToBoolean } from "@/lib/utils.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { sanitizeUrl } from "@docmost/editor-ext";
declare global { declare global {
interface Window { interface Window {
@@ -66,7 +67,7 @@ export function getFileUrl(src: string) {
if (src.startsWith("/files/")) { if (src.startsWith("/files/")) {
return getBackendUrl() + src; return getBackendUrl() + src;
} }
return src; return sanitizeUrl(src);
} }
export function getFileUploadSizeLimit() { export function getFileUploadSizeLimit() {
+6
View File
@@ -94,6 +94,12 @@ export function getPageIcon(icon: string, size = 18): string | ReactNode {
); );
} }
export const normalizeUrl = (url: string): string => {
if (!url) return url;
if (url.startsWith("/") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return url;
return `https://${url}`;
};
export function castToBoolean(value: unknown): boolean { export function castToBoolean(value: unknown): boolean {
if (value == null) { if (value == null) {
return false; return false;
+4
View File
@@ -53,6 +53,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false; const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
if (isLoading) { if (isLoading) {
return <></>; return <></>;
@@ -104,6 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
slugId={page.slugId} slugId={page.slugId}
spaceSlug={page?.space?.slug} spaceSlug={page?.space?.slug}
editable={canEdit} editable={canEdit}
canComment={canComment}
/> />
<MemoizedHistoryModal pageId={page.id} /> <MemoizedHistoryModal pageId={page.id} />
</div> </div>
@@ -8,6 +8,7 @@ import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section"; import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
import SessionList from "@/features/session/components/session-list";
export default function AccountSettings() { export default function AccountSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -36,6 +37,10 @@ export default function AccountSettings() {
<Divider my="lg" /> <Divider my="lg" />
<AccountMfaSection /> <AccountMfaSection />
<Divider my="lg" />
<SessionList />
</> </>
); );
} }
+1 -1
View File
@@ -68,7 +68,7 @@ export default function SharedPage() {
/> />
</Container> </Container>
{data && !shareId && !data.hasLicenseKey && <ShareBranding />} {data && !shareId && !(data.features?.length > 0) && <ShareBranding />}
</div> </div>
); );
} }
+15 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import * as path from "path"; import * as path from "path";
export const envPath = path.resolve(process.cwd(), "..", ".."); const envPath = path.resolve(process.cwd(), "..", "..");
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const { const {
@@ -35,6 +35,20 @@ export default defineConfig(({ mode }) => {
APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_VERSION: JSON.stringify(process.env.npm_package_version),
}, },
plugins: [react()], plugins: [react()],
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: "vendor-mantine", test: /@mantine/ },
{ name: "vendor-mermaid", test: /mermaid|cytoscape|elkjs/ },
{ name: "vendor-excalidraw", test: /excalidraw/ },
{ name: "vendor-katex", test: /katex/ },
],
},
},
},
},
resolve: { resolve: {
alias: { alias: {
"@": "/src", "@": "/src",
+56 -55
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.70.1", "version": "0.70.3",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -30,123 +30,124 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.29", "@ai-sdk/google": "^3.0.52",
"@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai": "^3.0.47",
"@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/openai-compatible": "^2.0.37",
"@aws-sdk/client-s3": "3.1000.0", "@aws-sdk/client-s3": "3.1014.0",
"@aws-sdk/lib-storage": "3.1000.0", "@aws-sdk/lib-storage": "3.1014.0",
"@aws-sdk/s3-request-presigner": "3.1000.0", "@aws-sdk/s3-request-presigner": "3.1014.0",
"@clickhouse/client": "^1.17.0", "@clickhouse/client": "^1.18.2",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@keyv/redis": "^5.1.6", "@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.29", "@langchain/core": "1.1.34",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0", "@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.14", "@nestjs/common": "^11.1.17",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.14", "@nestjs/core": "^11.1.17",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.0", "@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.14", "@nestjs/platform-fastify": "^11.1.17",
"@nestjs/platform-socket.io": "^11.1.14", "@nestjs/platform-socket.io": "^11.1.17",
"@nestjs/schedule": "^6.1.1", "@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1", "@nestjs/terminus": "^11.1.1",
"@nestjs/websockets": "^11.1.14", "@nestjs/websockets": "^11.1.17",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.7", "@react-email/components": "1.0.10",
"@react-email/render": "2.0.4", "@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.86", "ai": "^6.0.134",
"ai-sdk-ollama": "^3.7.0", "ai-sdk-ollama": "^3.8.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.70.1", "bowser": "^2.14.1",
"bullmq": "^5.71.0",
"cache-manager": "^7.2.8", "cache-manager": "^7.2.8",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"fs-extra": "^11.3.3", "fs-extra": "^11.3.4",
"happy-dom": "20.1.0", "happy-dom": "20.8.4",
"ioredis": "^5.4.1", "ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"kysely": "^0.28.2", "kysely": "^0.28.14",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0", "kysely-postgres-js": "^3.0.0",
"ldapts": "^7.4.0", "ldapts": "^8.1.7",
"lib0": "^0.2.117", "lib0": "^0.2.117",
"mammoth": "^1.11.0", "mammoth": "^1.12.0",
"mime-types": "^2.1.35", "mime-types": "^3.0.2",
"msgpackr": "^1.11.8", "msgpackr": "^1.11.9",
"nanoid": "3.3.11", "nanoid": "5.1.7",
"nestjs-cls": "^6.2.0", "nestjs-cls": "^6.2.0",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.5.0", "nestjs-pino": "^4.6.1",
"nodemailer": "^7.0.12", "nodemailer": "^8.0.3",
"openid-client": "^5.7.1", "openid-client": "^6.8.2",
"otpauth": "^9.4.1", "otpauth": "^9.5.0",
"p-limit": "^6.2.0", "p-limit": "^7.3.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.394", "pdfjs-dist": "^5.5.207",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1", "pgvector": "^0.2.1",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"postmark": "^4.0.5", "postmark": "^4.0.7",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2", "sanitize-filename-ts": "1.0.2",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"stripe": "^17.5.0", "stripe": "^17.7.0",
"tlds": "^1.261.0", "tlds": "^1.261.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"tseep": "^1.3.1", "tseep": "^1.3.1",
"typesense": "^2.1.0", "typesense": "^3.0.3",
"ws": "^8.19.0", "ws": "^8.19.0",
"yauzl": "^3.2.0", "yauzl": "^3.2.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.20.0", "@eslint/js": "^9.28.0",
"@nestjs/cli": "^11.0.16", "@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.1", "@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.0.10", "@nestjs/testing": "^11.1.17",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^6.0.0",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^3.0.1",
"@types/node": "^22.13.4", "@types/node": "^25.5.0",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^7.0.11",
"@types/passport-google-oauth20": "^2.0.16", "@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"eslint": "^9.39.2", "eslint": "^9.28.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.1.8",
"globals": "^15.15.0", "globals": "^17.4.0",
"jest": "^30.2.0", "jest": "^30.3.0",
"kysely-codegen": "^0.20.0", "kysely-codegen": "^0.20.0",
"prettier": "^3.5.1", "prettier": "^3.8.1",
"react-email": "5.2.8", "react-email": "5.2.10",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"ts-loader": "^9.5.4", "ts-loader": "^9.5.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.24.1" "typescript-eslint": "^8.57.1"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@@ -116,7 +116,7 @@ export class CollaborationGateway {
// Forward close events // Forward close events
client.on('close', (code: number, reason: Buffer) => { client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason); this.redisSync!.onSocketClose(socketId, code, reason.buffer as ArrayBuffer);
}); });
// Forward pong events for keepalive // Forward pong events for keepalive
@@ -5,6 +5,7 @@ import {
prosemirrorNodeToYElement, prosemirrorNodeToYElement,
tiptapExtensions, tiptapExtensions,
} from './collaboration.util'; } from './collaboration.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
@@ -27,6 +28,53 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default'); // const fragment = doc.getXmlFragment('default');
//}); //});
}, },
setCommentMark: async (
documentName: string,
payload: {
yjsSelection: YjsSelection;
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { yjsSelection, commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
setYjsMark(doc, fragment, yjsSelection, 'comment', {
commentId,
resolved,
});
},
);
},
resolveCommentMark: async (
documentName: string,
payload: {
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
updateYjsMarkAttribute(
fragment,
'comment',
{ name: 'commentId', value: commentId },
{ resolved },
);
},
);
},
updatePageContent: async ( updatePageContent: async (
documentName: string, documentName: string,
payload: { payload: {
@@ -58,8 +106,7 @@ export class CollaborationHandler {
} else { } else {
const newContent = prosemirrorJson.content || []; const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement); const yElements = newContent.map(prosemirrorNodeToYElement);
const position = const position = operation === 'prepend' ? 0 : fragment.length;
operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements); fragment.insert(position, yElements);
} }
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
import { import {
initProseMirrorDoc, initProseMirrorDoc,
relativePositionToAbsolutePosition, relativePositionToAbsolutePosition,
} from 'y-prosemirror'; } from '@tiptap/y-tiptap';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { Document } from '@hocuspocus/server'; import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core'; import { getSchema } from '@tiptap/core';
+22
View File
@@ -0,0 +1,22 @@
export const Feature = {
SSO_CUSTOM: 'sso:custom',
SSO_GOOGLE: 'sso:google',
MFA: 'mfa',
API_KEYS: 'api:keys',
COMMENT_RESOLUTION: 'comment:resolution',
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
-9
View File
@@ -91,15 +91,6 @@ export function extractBearerTokenFromHeader(
return type === 'Bearer' ? token : undefined; return type === 'Bearer' ? token : undefined;
} }
export function hasLicenseOrEE(opts: {
licenseKey: string;
plan: string;
isCloud: boolean;
}): boolean {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
}
/** /**
* Normalizes a database URL for postgres.js compatibility. * Normalizes a database URL for postgres.js compatibility.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values * - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
@@ -7,6 +7,7 @@ export interface AuditContext {
actorId: string | null; actorId: string | null;
actorType: 'user' | 'system' | 'api_key'; actorType: 'user' | 'system' | 'api_key';
ipAddress: string | null; ipAddress: string | null;
userAgent: string | null;
} }
export const AUDIT_CONTEXT_KEY = 'auditContext'; export const AUDIT_CONTEXT_KEY = 'auditContext';
@@ -19,11 +20,15 @@ export class AuditContextMiddleware implements NestMiddleware {
const workspaceId = (req as any).workspaceId ?? null; const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = this.extractIpAddress(req); const ipAddress = this.extractIpAddress(req);
const userAgent =
(req.headers['user-agent'] as string) ?? null;
const auditContext: AuditContext = { const auditContext: AuditContext = {
workspaceId, workspaceId,
actorId: null, actorId: null,
actorType: 'user', actorType: 'user',
ipAddress, ipAddress,
userAgent,
}; };
this.cls.set(AUDIT_CONTEXT_KEY, auditContext); this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
@@ -70,8 +70,8 @@ export class AttachmentService {
} }
if ( if (
existingAttachment.pageId !== pageId && existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension && existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId existingAttachment.workspaceId !== workspaceId
) { ) {
throw new BadRequestException('File attachment does not match'); throw new BadRequestException('File attachment does not match');

Some files were not shown because too many files have changed in this diff Show More