Compare commits

...

36 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
Philipinho 236a63dadc sync 2026-03-15 17:09:29 +00:00
Philip Okugbe 89b94e5d19 feat: refactor link menu (#2025)
* link markview - WIP

* WIP

* feat: refactor links

* cleanup
2026-03-15 17:08:59 +00:00
Philip Okugbe 97c459be67 feat(cloud): add find-workspace and email verification endpoints (#2020)
* feat: add find-workspace and email verification endpoints
* sync
2026-03-14 13:36:30 +00:00
Philip Okugbe d0ed6865cb fix page level comment on mobile (#2018)
* add icon next to comment box
2026-03-14 01:01:24 +00:00
Philip Okugbe 65b89a1b24 fix email button (#2017) 2026-03-14 00:40:32 +00:00
Philip Okugbe 1fdee33206 feat(editor): add auto-save and unsaved changes protection for diagrams (#2011)
* feat(editor): add auto-save and unsaved changes protection for diagrams
* 30 seconds
2026-03-13 17:58:29 +00:00
Philip Okugbe 7b69727a30 fix shared page mention view for non-logged in users (#2008) 2026-03-11 19:25:27 +00:00
Philip Okugbe 66c26af34b noop audit module (#1994) 2026-03-05 09:29:39 +00:00
Philip Okugbe b4f009513e fix: resize handle clipping (#1990) 2026-03-04 12:24:46 +00:00
Philipinho fcffa3dfa0 fix media 2026-03-04 12:08:08 +00:00
Philipinho 1980b94825 0.70.1 2026-03-04 11:57:31 +00:00
Philip Okugbe bea1637519 fix: image fallback regression (#1989)
* fix: image fallback regression

* fix image preview on upload

* fix image loading
2026-03-04 11:51:43 +00:00
193 changed files with 9159 additions and 5155 deletions
+39 -39
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.70.0", "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"
} }
} }
@@ -289,6 +289,11 @@
"Save & Exit": "Save & Exit", "Save & Exit": "Save & Exit",
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram", "Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
"Paste link": "Paste link", "Paste link": "Paste link",
"Paste link or search pages": "Paste link or search pages",
"Link to web page": "Link to web page",
"Recents": "Recents",
"Page or URL": "Page or URL",
"Link title": "Link title",
"Edit link": "Edit link", "Edit link": "Edit link",
"Remove link": "Remove link", "Remove link": "Remove link",
"Add link": "Add link", "Add link": "Add link",
@@ -437,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",
@@ -621,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",
@@ -629,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",
@@ -654,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",
@@ -693,5 +700,31 @@
"Failed to update trash retention": "Failed to update trash retention", "Failed to update trash retention": "Failed to update trash retention",
"Removed page restriction": "Removed page restriction", "Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission", "Added page permission": "Added page permission",
"Removed page permission": "Removed page permission" "Removed page permission": "Removed page permission",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
"Check your email": "Check your email",
"We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.",
"We sent a verification link to your email.": "We sent a verification link to your email.",
"Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.",
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.",
"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."
} }
+2
View File
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -63,6 +64,7 @@ export default function App() {
<> <>
<Route path={"/create"} element={<CreateWorkspace />} /> <Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} /> <Route path={"/select"} element={<CloudLogin />} />
<Route path={"/verify-email"} element={<VerifyEmail />} />
</> </>
)} )}
@@ -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
> >
@@ -34,6 +34,7 @@ export function AutoTooltipText({
disabled={!isTruncated || !label} disabled={!isTruncated || !label}
multiline multiline
withArrow withArrow
withinPortal={false}
{...tooltipProps} {...tooltipProps}
> >
<Text <Text
@@ -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 />
@@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined"); const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data; return req.data;
} }
export async function findWorkspacesByEmail(email: string): Promise<void> {
await api.post("/workspace/find-by-email", { email });
}
export async function verifyEmail(data: { token: string }): Promise<void> {
await api.post("/workspace/verify-email", data);
}
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
await api.post("/workspace/resend-verification", data);
}
@@ -20,14 +20,22 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next"; 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 { 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" }),
}); });
const findWorkspaceSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
});
export function CloudLoginForm() { export function CloudLoginForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery(); const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({ const form = useForm<any>({
@@ -37,6 +45,13 @@ export function CloudLoginForm() {
}, },
}); });
const findForm = useForm<any>({
validate: zod4Resolver(findWorkspaceSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: { hostname: string }) { async function onSubmit(data: { hostname: string }) {
setIsLoading(true); setIsLoading(true);
@@ -54,8 +69,21 @@ export function CloudLoginForm() {
setIsLoading(false); setIsLoading(false);
} }
async function onFindSubmit(data: { email: string }) {
setIsFindLoading(true);
try {
await findWorkspacesByEmail(data.email);
setFindEmailSent(true);
} catch {
findForm.setFieldError("email", "An error occurred. Please try again.");
}
setIsFindLoading(false);
}
return ( 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">
@@ -83,15 +111,47 @@ export function CloudLoginForm() {
{t("Continue")} {t("Continue")}
</Button> </Button>
</form> </form>
<Divider my="lg" label="or" labelPosition="center" />
{findEmailSent ? (
<Text ta="center" size="sm" c="dimmed">
{t("We've sent you an email with your associated workspaces.")}
</Text>
) : (
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
<Text fw={600} mb="xs">
{t("Find your workspaces")}
</Text>
<TextInput
type="email"
placeholder="name@company.com"
description={t(
"We'll send a list of your workspaces to this email.",
)}
withErrorStyles={false}
{...findForm.getInputProps("email")}
/>
<Button
type="submit"
fullWidth
mt="md"
variant="light"
loading={isFindLoading}
>
{t("Send")}
</Button>
</form>
)}
</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}>
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Container, Title, Text, Button, Box } from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import {
verifyEmail,
resendVerificationEmail,
} from "@/ee/cloud/service/cloud-service.ts";
import { notifications } from "@mantine/notifications";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
export default function VerifyEmail() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const rawEmail = searchParams.get("email");
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
const sig = searchParams.get("sig");
const [isResending, setIsResending] = useState(false);
const [resent, setResent] = useState(false);
useEffect(() => {
if (token) {
handleVerify(token);
}
}, [token]);
async function handleVerify(verifyToken: string) {
try {
await verifyEmail({ token: verifyToken });
navigate(APP_ROUTE.HOME);
} catch (err) {
notifications.show({
message: t("Verification failed. The link may have expired."),
color: "red",
});
navigate(APP_ROUTE.AUTH.LOGIN);
}
}
async function handleResend() {
if (!email || !sig) return;
setIsResending(true);
try {
await resendVerificationEmail({ email, sig });
setResent(true);
} catch {
notifications.show({
message: t("Failed to resend verification email. Please try again."),
color: "red",
});
}
setIsResending(false);
}
if (token) {
return (
<AuthLayout>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Verifying your email")}
</Title>
<Text ta="center" c="dimmed">
{t("Please wait...")}
</Text>
</Box>
</Container>
</AuthLayout>
);
}
return (
<AuthLayout>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Check your email")}
</Title>
<Text ta="center" c="dimmed" mb="md">
{email
? t("We sent a verification link to {{email}}.", { email })
: t("We sent a verification link to your email.")}
</Text>
<Text ta="center" size="sm" c="dimmed" mb="lg">
{t("Click the link to verify your email and access your workspace.")}
</Text>
{email && sig && !resent && (
<Button
fullWidth
variant="light"
onClick={handleResend}
loading={isResending}
>
{t("Resend verification email")}
</Button>
)}
{resent && (
<Text ta="center" size="sm" c="dimmed">
{t("Verification email sent. Please check your inbox.")}
</Text>
)}
</Box>
</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,14 +19,15 @@ 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(),
name: z.string().min(1).max(50), name: z.string().min(1, { message: "Name is required" }).max(50),
email: z email: z
.email() .email({ message: "Invalid email address" })
.min(1, { message: "email is required" }), .min(1, { message: "Email is required" }),
password: z.string().min(8), password: z.string().min(8, { message: "Password must be at least 8 characters" }),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -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>
); );
} }
@@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils"; import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts"; import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() { export default function useAuth() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,9 +52,18 @@ export default function useAuth() {
} }
} catch (err) { } catch (err) {
setIsLoading(false); setIsLoading(false);
console.log(err);
const message = err.response?.data?.message;
if (isCloud() && message?.includes("verify your email")) {
const sig = err.response?.data?.emailSignature;
navigate(
`${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`,
);
return;
}
notifications.show({ notifications.show({
message: err.response?.data.message, message,
color: "red", color: "red",
}); });
} }
@@ -92,6 +101,17 @@ export default function useAuth() {
try { try {
if (isCloud()) { if (isCloud()) {
const res = await createWorkspace(data); const res = await createWorkspace(data);
if (res?.requiresEmailVerification) {
const hostname = res?.workspace?.hostname;
if (hostname) {
window.location.href =
getHostnameUrl(hostname) +
`/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`;
}
return;
}
const hostname = res?.workspace?.hostname; const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken; const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) { if (hostname && exchangeToken) {
@@ -50,4 +50,5 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
export async function getCollabToken(): Promise<ICollabToken> { export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token"); const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data; return req.data;
} }
@@ -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);
@@ -24,7 +24,12 @@ function CommentActions({
</Button> </Button>
)} )}
<Button size="compact-sm" loading={isLoading} onClick={onSave}> <Button
size="compact-sm"
loading={isLoading}
onClick={onSave}
onMouseDown={(e) => e.preventDefault()}
>
{t("Save")} {t("Save")}
</Button> </Button>
</Group> </Group>
@@ -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}
@@ -27,6 +27,9 @@ import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
function CommentListWithTabs() { function CommentListWithTabs() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -41,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(() => {
@@ -150,7 +155,7 @@ function CommentListWithTabs() {
)} )}
</Paper> </Paper>
), ),
[comments, handleAddReply, isLoading, space?.membership?.role], [comments, handleAddReply, isLoading, space?.membership?.role, canComment],
); );
if (isCommentsLoading) { if (isCommentsLoading) {
@@ -345,6 +350,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin(); const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null); const commentEditorRef = useRef(null);
const [currentUser] = useAtom(currentUserAtom);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
onSave(null, content); onSave(null, content);
@@ -363,19 +369,30 @@ const PageCommentInput = ({ onSave, isLoading }) => {
position: "relative", position: "relative",
}} }}
> >
<CommentEditor <Group wrap="nowrap" align="flex-start" gap="xs">
ref={commentEditorRef} <CustomAvatar
onUpdate={setContent} size="sm"
onSave={handleSave} avatarUrl={currentUser?.user?.avatarUrl}
editable={true} name={currentUser?.user?.name}
placeholder={t("Add a comment...")} style={{ flexShrink: 0, marginTop: 10 }}
/> />
<div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
</div>
</Group>
{focused && ( {focused && (
<ActionIcon <ActionIcon
variant="filled" variant="filled"
radius="xl" radius="xl"
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
loading={isLoading} loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }} style={{ position: "absolute", right: 8, bottom: 30 }}
> >
@@ -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,66 +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 { 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) => {
setIsOpen(false);
editor
.chain()
.focus()
.setLink({ href: url })
.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={300} <ActionIcon
opened={isOpen} variant="default"
trapFocus size="lg"
offset={{ mainAxis: 35, crossAxis: 0 }} radius="0"
withArrow style={{ border: "none" }}
> onClick={() => setShowLinkMenu(true)}
<Popover.Target> >
<Tooltip label={t("Add link")} withArrow> <IconLink size={16} />
<ActionIcon </ActionIcon>
variant="default" </Tooltip>
size="lg"
radius="0"
style={{
border: "none",
}}
onClick={() => setIsOpen(!isOpen)}
>
<IconLink size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<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>
);
};
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -8,7 +8,9 @@ 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,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
@@ -29,10 +31,12 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -41,6 +45,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -131,33 +139,14 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const handleOpen = useCallback(async () => { const saveData = useCallback(async (svgXml: string) => {
if (!editorState?.src) return; if (isSavingRef.current) return;
isSavingRef.current = true;
setIsSaving(true);
try { try {
const url = getFileUrl(editorState.src); const svgString = decodeBase64ToSvgString(svgXml);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -179,10 +168,88 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}, [editor, editorState?.attachmentId]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close(); close();
}, return;
[editor, editorState?.attachmentId, close], }
);
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
});
}, [close, t]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
setIsLoading(true);
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
setIsLoading(false);
isDirtyRef.current = false;
open();
}
}, [editorState?.src, open]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 60_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
return ( return (
<> <>
@@ -247,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>
@@ -276,15 +344,17 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
<Modal.Root opened={opened} onClose={close} fullScreen> <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}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -296,13 +366,19 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
}} }}
/> />
</div> </div>
@@ -2,11 +2,12 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { import {
ActionIcon, ActionIcon,
Card, Card,
LoadingOverlay,
Modal, Modal,
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl } from "@/lib/config.ts"; import { getDrawioUrl } from "@/lib/config.ts";
@@ -14,6 +15,7 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
@@ -21,6 +23,7 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx"; import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
export default function DrawioView(props: NodeViewProps) { export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,50 +33,121 @@ export default function DrawioView(props: NodeViewProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
open(); open();
}; };
const handleSave = async (data: EventSave) => { const saveData = async (svgXml: string, updateSrc = true) => {
const svgString = decodeBase64ToSvgString(data.xml); if (isSavingRef.current) return;
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore isSavingRef.current = true;
const pageId = editor.storage?.pageId; setIsSaving(true);
let attachment: IAttachment = null; try {
if (attachmentId) { const svgString = decodeBase64ToSvgString(svgXml);
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); const fileName = "diagram.drawio.svg";
} else { const drawioSVGFile = await svgStringToFile(svgString, fileName);
attachment = await uploadFile(drawioSVGFile, pageId);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
};
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 30_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={close} fullScreen> <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}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -85,13 +159,19 @@ export default function DrawioView(props: NodeViewProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml, true).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data, false).catch(() => {});
}} }}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -10,9 +10,11 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Group, Group,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { import {
@@ -52,6 +54,12 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}); });
const [excalidrawData, setExcalidrawData] = useState<any>(null); const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -147,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, {
@@ -160,57 +169,112 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
setIsLoading(false);
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
} }
}, [editorState?.src, open]); }, [editorState?.src, open]);
const handleSave = useCallback(async () => { const saveData = useCallback(async () => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
setIsSaving(true);
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); const attachmentId = editorState?.attachmentId;
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId); let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}, [editor, excalidrawAPI, editorState?.attachmentId]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
// save failed, modal stays open
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
editor.commands.updateAttributes("excalidraw", { modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}, [editor, excalidrawAPI, editorState?.attachmentId, close]); if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData().catch(() => {});
}
}, 60_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<> <>
@@ -281,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>
@@ -317,7 +382,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -332,10 +397,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -343,6 +408,18 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -7,7 +7,14 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { lazy, Suspense, useState } from "react"; import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib"; import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
@@ -20,6 +27,7 @@ import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { modals } from "@mantine/modals";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({ import("@excalidraw/excalidraw").then((module) => ({
@@ -42,59 +50,125 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const [isSaving, setIsSaving] = useState(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
}; };
const handleSave = async () => { const saveData = useCallback(async (updateSrc = true) => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
setIsSaving(true);
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else { let attachment: IAttachment = null;
attachment = await uploadFile(excalidrawSvgFile, pageId); if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}, [excalidrawAPI, editor, attachmentId, updateAttributes]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
/* empty */
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData(false).catch(() => {});
}
}, 30_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
@@ -105,7 +179,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -120,10 +194,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -131,6 +205,18 @@ export default function ExcalidrawView(props: NodeViewProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -2,6 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
max-width: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
animation: pulse 1.2s ease-in-out infinite; animation: pulse 1.2s ease-in-out infinite;
@@ -1,36 +1,200 @@
import React from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button, Group, TextInput } from "@mantine/core"; import {
import { IconLink } from "@tabler/icons-react"; Group,
ScrollArea,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { IconFileDescription, IconLink, IconWorld } from "@tabler/icons-react";
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx"; import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts"; import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
import clsx from "clsx";
import classes from "./link.module.css";
export const LinkEditorPanel = ({ export const LinkEditorPanel = ({
onSetLink, onSetLink,
initialUrl, initialUrl,
onUnsetLink,
}: LinkEditorPanelProps) => { }: LinkEditorPanelProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const state = useLinkEditorState({ const { spaceSlug } = useParams();
onSetLink, const { data: space } = useSpaceQuery(spaceSlug);
initialUrl, const state = useLinkEditorState({ onSetLink, initialUrl });
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: suggestion } = useSearchSuggestionsQuery({
query: state.isSearchQuery ? state.url : "",
includeUsers: false,
includePages: true,
spaceId: space?.id,
limit: state.isSearchQuery ? 10 : 3,
preload: true,
}); });
const pages: Partial<IPage>[] = suggestion?.pages ?? [];
useEffect(() => {
setSelectedIndex(0);
}, [pages.length]);
const selectPage = useCallback(
(page: Partial<IPage>) => {
const url = buildPageUrl(
page.space?.slug || spaceSlug,
page.slugId,
page.title,
);
onSetLink(url, true);
},
[onSetLink, spaceSlug],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const hasUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery);
const total = (hasUrlItem ? 1 : 0) + (state.isValidUrl ? 0 : pages.length);
if (total === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, total - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (hasUrlItem && selectedIndex === 0) {
onSetLink(state.url, false);
} else {
const pageIndex = hasUrlItem ? selectedIndex - 1 : selectedIndex;
if (pageIndex >= 0 && pageIndex < pages.length) {
selectPage(pages[pageIndex]);
}
}
}
},
[pages, selectedIndex, selectPage, state.isValidUrl, state.isSearchQuery, state.url, onSetLink],
);
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const showPages = pages.length > 0 && !state.isValidUrl;
const showUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery);
const showDropdown = showPages || showUrlItem;
return ( return (
<div> <div>
<form onSubmit={state.handleSubmit}> <form onSubmit={state.handleSubmit}>
<Group gap="xs" style={{ flex: 1 }} wrap="nowrap"> <TextInput
<TextInput leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
leftSection={<IconLink size={16} />} classNames={{ input: classes.linkInput }}
variant="filled" placeholder={t("Paste link or search pages")}
placeholder={t("Paste link")} value={state.url}
value={state.url} onChange={state.onChange}
onChange={state.onChange} onKeyDown={handleKeyDown}
/> data-autofocus
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}> autoFocus
{t("Save")} />
</Button>
</Group>
</form> </form>
{showDropdown && (
<>
{!state.isSearchQuery && !state.isValidUrl && (
<Text c="dimmed" size="xs" fw={600} px="sm" pt={10} pb={4}>
{t("Recents")}
</Text>
)}
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={300}
scrollbars="y"
scrollbarSize={6}
mt={state.url.length > 0 ? 8 : 0}
styles={{ content: { minWidth: 0 } }}
>
{showUrlItem && (
<UnstyledButton
data-item-index={0}
onClick={() => onSetLink(state.url, false)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: selectedIndex === 0,
})}
>
<Group gap={10} wrap="nowrap" align="flex-start">
<span className={classes.pageIcon}>
<IconWorld size={18} stroke={1.5} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate lh={1.3}>
{state.url}
</Text>
<Text size="xs" c="dimmed" lh={1.4}>
{t("Link to web page")}
</Text>
</div>
</Group>
</UnstyledButton>
)}
{!state.isValidUrl && pages.map((page, index) => {
const itemIndex = showUrlItem ? index + 1 : index;
return (
<UnstyledButton
data-item-index={itemIndex}
key={page.id || index}
onClick={() => selectPage(page)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: itemIndex === selectedIndex,
})}
>
<Group gap={10} wrap="nowrap" align="flex-start">
<span className={classes.pageIcon}>
{page.icon || <IconFileDescription size={18} stroke={1.5} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<AutoTooltipText size="sm" fw={500} truncate lh={1.3}>
{page.title || t("Untitled")}
</AutoTooltipText>
{page.space?.name && (
<AutoTooltipText size="xs" c="dimmed" truncate lh={1.4}>
{page.space.name}
</AutoTooltipText>
)}
</div>
</Group>
</UnstyledButton>
);
})}
</ScrollArea.Autosize>
</>
)}
{onUnsetLink && (
<UnstyledButton
onClick={onUnsetLink}
className={classes.removeLink}
>
<Text size="sm" c="red">
{t("Remove link")}
</Text>
</UnstyledButton>
)}
</div> </div>
); );
}; };
@@ -1,103 +1,114 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { FC, useCallback, useEffect, useRef } from "react";
import React, { useCallback, useState } 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 { TextSelection } from "@tiptap/pm/state";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts"; import { Paper } from "@mantine/core";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) { type EditorLinkMenuProps = {
const [showEdit, setShowEdit] = useState(false); editor: Editor;
};
const shouldShow = useCallback(() => { export const EditorLinkMenu: FC<EditorLinkMenuProps> = ({ editor }) => {
return editor.isActive("link"); const [showLinkMenu, setShowLinkMenu] = useAtom(showLinkMenuAtom);
}, [editor]); const showLinkMenuRef = useRef(showLinkMenu);
const editorState = useEditorState({ const containerRef = useRef<HTMLDivElement>(null);
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => { useEffect(() => {
setShowEdit(true); 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( const onSetLink = useCallback(
(url: string) => { (url: string, internal?: boolean) => {
editor editor
.chain() .chain()
.focus() .focus()
.extendMarkRange("link") .setLink({
.setLink({ href: url }) href: internal ? url : normalizeUrl(url),
internal: !!internal,
} as any)
.command(({ tr }) => { .command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true; return true;
}) })
.run(); .run();
setShowEdit(false); setShowLinkMenu(false);
}, },
[editor], [editor, setShowLinkMenu],
); );
const onUnsetLink = useCallback(() => { useEffect(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run(); if (!showLinkMenu) return;
setShowEdit(false);
return null;
}, [editor]);
const onShowEdit = useCallback(() => { const dismiss = () => {
setShowEdit(true); setShowLinkMenu(false);
}, []); editor.commands.focus();
editor.commands.setTextSelection(editor.state.selection.to);
};
const onHideEdit = useCallback(() => { const handleKeyDown = (e: KeyboardEvent) => {
setShowEdit(false); 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 ( return (
<BaseBubbleMenu <BubbleMenu
editor={editor} editor={editor}
pluginKey={`link-menu`} shouldShow={({ editor, state }) => {
updateDelay={0} const { empty } = state.selection;
options={{ return (
onHide: () => { showLinkMenuRef.current &&
setShowEdit(false); editor.isEditable &&
}, !empty &&
placement: "bottom", isTextSelected(editor)
offset: 5, );
// zIndex: 101,
}} }}
shouldShow={shouldShow} options={{
placement: "bottom",
offset: 8,
onShow: focusInput,
onHide: () => {
setShowLinkMenu(false);
},
}}
style={{ zIndex: 198, position: "relative" }}
> >
{showEdit ? ( <Paper ref={containerRef} w={320} p="sm" shadow="md" radius={6} withBorder>
<Card <LinkEditorPanel onSetLink={onSetLink} />
withBorder </Paper>
radius="md" </BubbleMenu>
padding="xs"
bg="var(--mantine-color-body)"
>
<LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card>
) : (
<LinkPreviewPanel
url={editorState?.href}
onClear={onUnsetLink}
onEdit={handleEdit}
/>
)}
</BaseBubbleMenu>
); );
} };
export default LinkMenu;
@@ -1,60 +0,0 @@
import {
Tooltip,
ActionIcon,
Card,
Divider,
Anchor,
Flex,
} from "@mantine/core";
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./link.module.css";
export type LinkPreviewPanelProps = {
url: string;
onEdit: () => void;
onClear: () => void;
};
export const LinkPreviewPanel = ({
onClear,
onEdit,
url,
}: LinkPreviewPanelProps) => {
const { t } = useTranslation();
return (
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
<Flex align="center">
<Tooltip label={url}>
<Anchor
href={url}
target="_blank"
rel="noopener noreferrer"
className={classes.link}
>
{url}
</Anchor>
</Tooltip>
<Flex align="center">
<Divider mx={4} orientation="vertical" />
<Tooltip label={t("Edit link")}>
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Remove link")}>
<ActionIcon onClick={onClear} variant="subtle" color="red">
<IconLinkOff size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
</Card>
</>
);
};
@@ -0,0 +1,578 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import {
IconFileDescription,
IconCopy,
IconExternalLink,
IconLinkOff,
IconPencil,
IconWorld,
} from "@tabler/icons-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { notifications } from "@mantine/notifications";
import {
Divider,
Group,
Popover,
Text,
TextInput,
ActionIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./link.module.css";
import { useTranslation } from "react-i18next";
import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
const parseInternalLink = (
href: string,
internalAttr?: boolean,
): { isInternal: boolean; slugId: string | null; label: string } => {
if (!href) return { isInternal: !!internalAttr, slugId: null, label: "" };
const match = INTERNAL_LINK_REGEX.exec(href);
if (!match) {
if (internalAttr) return { isInternal: true, slugId: null, label: href };
return { isInternal: false, slugId: null, label: href };
}
const isExternal = match[2] && match[2] !== window.location.host;
const slug = match[5];
const slugId = extractPageSlugId(slug);
const namePart = slug.split("-").slice(0, -1).join("-");
return {
isInternal: !isExternal,
slugId,
label: namePart || slug,
};
};
export default function LinkView(props: MarkViewProps) {
const { mark, editor } = props;
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
const { shareId, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
>("closed");
const [linkTitle, setLinkTitle] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [showSearch, setShowSearch] = useState(false);
const lastOpenState = useRef<"preview" | "edit">("preview");
const wrapperRef = useRef<HTMLSpanElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const isEditable = editor.isEditable;
const {
isInternal,
slugId,
label: linkLabel,
} = parseInternalLink(href, mark.attrs.internal);
const isPopoverVisible = popoverState !== "closed";
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
const pageTitle = isShareRoute
? sharedPageData?.page?.title
: linkedPage?.title;
const pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const getLinkPos = useCallback((): number | null => {
if (!wrapperRef.current) return null;
try {
return editor.view.posAtDOM(wrapperRef.current, 0);
} catch {
return null;
}
}, [editor]);
const handleUpdateLinkTitle = useCallback(
(newTitle: string) => {
if (!newTitle) return;
const pos = getLinkPos();
if (pos === null) return;
const { state } = editor;
const resolved = state.doc.resolve(pos);
const node = resolved.nodeAfter;
if (!node?.isText) return;
const linkMark = node.marks.find(
(m) => m.type.name === "link" && m.attrs.href === href,
);
if (!linkMark || node.text === newTitle) return;
const from = pos;
const to = pos + node.nodeSize;
const { tr } = state;
tr.insertText(newTitle, from, to);
tr.addMark(from, from + newTitle.length, linkMark);
editor.view.dispatch(tr);
},
[editor, href, getLinkPos],
);
const handleEditLink = useCallback(
(url: string, internal?: boolean) => {
const normalizedUrl = internal ? url : normalizeUrl(url);
const pos = getLinkPos();
if (pos === null) {
setPopoverState("closed");
return;
}
const { state } = editor;
const resolved = state.doc.resolve(pos);
const node = resolved.nodeAfter;
if (!node?.isText) {
setPopoverState("closed");
return;
}
const linkMark = node.marks.find(
(m) => m.type.name === "link" && m.attrs.href === href,
);
if (linkMark) {
const from = pos;
const to = pos + node.nodeSize;
const { tr } = state;
tr.removeMark(from, to, linkMark.type);
tr.addMark(
from,
to,
linkMark.type.create({ href: normalizedUrl, internal: !!internal }),
);
editor.view.dispatch(tr);
}
setPopoverState("closed");
},
[editor, href, getLinkPos],
);
useEffect(() => {
if (popoverState === "edit") {
const text = wrapperRef.current?.querySelector("a")?.textContent || "";
setLinkTitle(text);
setLinkUrl(href);
pendingTitleRef.current = null;
requestAnimationFrame(() => titleInputRef.current?.focus());
}
if (popoverState === "closed") {
if (pendingTitleRef.current !== null) {
handleUpdateLinkTitle(pendingTitleRef.current);
pendingTitleRef.current = null;
}
setShowSearch(false);
}
}, [popoverState, href, isInternal, handleUpdateLinkTitle]);
useEffect(() => {
if (popoverState !== "closed") {
lastOpenState.current = popoverState;
}
}, [popoverState]);
useEffect(() => {
if (!isPopoverVisible) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
wrapperRef.current?.contains(target) ||
dropdownRef.current?.contains(target)
) {
return;
}
setPopoverState("closed");
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setPopoverState("closed");
}
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("keydown", handleEscape, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("keydown", handleEscape, true);
};
}, [isPopoverVisible]);
const handleNavigate = useCallback(() => {
if (!href) return;
if (isInternal) {
let targetPath = href;
let anchor = "";
try {
const url = new URL(href);
targetPath = url.pathname;
anchor = url.hash.slice(1);
} catch {
if (href.includes("#")) {
[targetPath, anchor] = href.split("#");
}
}
if (anchor) {
const currentPageSlugId = extractPageSlugId(pageSlug);
if (!slugId || currentPageSlugId === slugId) {
const element =
document.querySelector(`[id="${anchor}"]`) ||
document.querySelector(`[data-id="${anchor}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`${location.pathname}#${anchor}`, { replace: true });
return;
}
}
}
if (isShareRoute && slugId) {
const sharedUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
pageTitle: pageTitle,
anchorId: anchor || undefined,
});
navigate(sharedUrl);
} else {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
}
} else {
window.open(
sanitizeUrl(normalizeUrl(href)),
"_blank",
"noopener,noreferrer",
);
}
}, [
href,
navigate,
location.pathname,
isInternal,
isShareRoute,
slugId,
shareId,
pageTitle,
pageSlug,
]);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isEditable) {
setPopoverState("preview");
} else {
handleNavigate();
}
},
[handleNavigate, isEditable],
);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const fullUrl = sanitizeUrl(
isInternal ? `${window.location.origin}${href}` : href,
);
copyToClipboard(fullUrl);
notifications.show({
message: t("Link copied"),
});
setPopoverState("closed");
},
[href, isInternal, t],
);
const handleRemoveLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setPopoverState("closed");
}, [editor]);
const displayHref = sanitizeUrl(
isInternal
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
);
const linkTitleInput = (
<>
<Text size="xs" fw={600} c="dimmed" mt="sm" mb={4}>
{t("Link title")}
</Text>
<TextInput
ref={titleInputRef}
classNames={{ input: classes.linkInput }}
value={linkTitle}
onChange={(e) => {
const val = e.currentTarget.value;
setLinkTitle(val);
pendingTitleRef.current = val;
const anchor = wrapperRef.current?.querySelector("a");
if (anchor && val) {
const walker = document.createTreeWalker(
anchor,
NodeFilter.SHOW_TEXT,
);
const textNode = walker.nextNode();
if (textNode) {
const view = editor.view as any;
view.domObserver.stop();
textNode.nodeValue = val;
view.domObserver.start();
}
}
}}
onBlur={() => {
if (pendingTitleRef.current !== null) {
handleUpdateLinkTitle(pendingTitleRef.current);
pendingTitleRef.current = null;
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdateLinkTitle(linkTitle);
pendingTitleRef.current = null;
setPopoverState("closed");
}
}}
size="sm"
/>
</>
);
return (
<Popover
opened={isPopoverVisible}
width={activeView === "edit" ? 320 : undefined}
position="bottom"
withArrow
shadow="md"
trapFocus={false}
closeOnClickOutside={false}
>
<Popover.Target>
<span
ref={wrapperRef}
className={classes.linkWrapper}
onClick={handleClick}
>
<a
href={displayHref}
spellCheck={false}
onClick={(e) => e.preventDefault()}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
>
<MarkViewContent />
</a>
</span>
</Popover.Target>
<Popover.Dropdown
ref={dropdownRef}
p={activeView === "edit" ? "sm" : 6}
onMouseDown={(e) => e.stopPropagation()}
>
{activeView === "edit" ? (
<>
<Text size="xs" fw={600} c="dimmed" mb={4}>
{t("Page or URL")}
</Text>
{isInternal ? (
!showSearch ? (
<>
<UnstyledButton
className={classes.linkChip}
onClick={() => setShowSearch(true)}
>
<IconFileDescription
size={16}
stroke={1.5}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0 }}
/>
<Text size="sm" fw={500} truncate>
{pageTitle || linkTitle}
</Text>
</UnstyledButton>
{linkTitleInput}
<Divider my="xs" />
<UnstyledButton
onClick={handleRemoveLink}
className={classes.removeLink}
>
<Group gap={8}>
<IconLinkOff size={16} stroke={1.5} />
<Text size="sm">{t("Remove link")}</Text>
</Group>
</UnstyledButton>
</>
) : (
<LinkEditorPanel
onSetLink={handleEditLink}
onUnsetLink={handleRemoveLink}
/>
)
) : (
<>
<TextInput
leftSection={
<IconWorld
size={16}
stroke={1.5}
color="var(--mantine-color-dimmed)"
/>
}
classNames={{ input: classes.linkInput }}
value={linkUrl}
onChange={(e) => setLinkUrl(e.currentTarget.value)}
onBlur={() => {
if (linkUrl && linkUrl !== href) {
handleEditLink(linkUrl, false);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (linkUrl && linkUrl !== href) {
handleEditLink(linkUrl, false);
}
}
}}
size="sm"
/>
{linkTitleInput}
<Divider my="xs" />
<UnstyledButton
onClick={handleRemoveLink}
className={classes.removeLink}
>
<Group gap={8}>
<IconLinkOff size={16} stroke={1.5} />
<Text size="sm">{t("Remove link")}</Text>
</Group>
</UnstyledButton>
</>
)}
</>
) : (
<Group gap={4} wrap="nowrap">
<Group
component="a"
//@ts-ignore
href={displayHref}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
gap={6}
wrap="nowrap"
style={{
cursor: "pointer",
maxWidth: 250,
textDecoration: "none",
color: "inherit",
userSelect: "none",
}}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
handleNavigate();
}}
>
{isInternal ? (
<IconFileDescription size={18} color="gray" />
) : (
<IconExternalLink size={18} color="gray" />
)}
<Text size="sm" truncate fw={500}>
{isInternal ? pageTitle || linkLabel : href}
</Text>
</Group>
<Divider orientation="vertical" />
<Tooltip label={t("Edit link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setShowSearch(false);
setPopoverState("edit");
}}
>
<IconPencil size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Copy link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopy(e);
}}
>
<IconCopy size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Remove link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveLink();
}}
>
<IconLinkOff size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,6 +1,102 @@
.link { .linkWrapper {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); position: relative;
overflow: hidden; display: inline;
text-overflow: ellipsis; }
white-space: nowrap;
} .linkInput {
border: 1.5px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: transparent;
&:focus {
border-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-6)
);
box-shadow: 0 0 0 1px
light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6));
}
}
.pageIcon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--mantine-color-dimmed);
font-size: 16px;
margin-top: 2px;
}
.searchItem {
width: 100%;
padding: 7px 4px;
color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
}
.selectedSearchItem {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
.linkChip {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
cursor: pointer;
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-dark-5);
}
&:hover {
@mixin light {
background: var(--mantine-color-gray-2);
}
@mixin dark {
background: var(--mantine-color-dark-4);
}
}
}
.removeLink {
width: 100%;
padding: 4px;
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-dark-5);
}
}
}
@@ -1,4 +1,5 @@
export type LinkEditorPanelProps = { export type LinkEditorPanelProps = {
initialUrl?: string; initialUrl?: string;
onSetLink: (url: string, openInNewTab?: boolean) => void; onSetLink: (url: string, internal?: boolean) => void;
onUnsetLink?: () => void;
}; };
@@ -13,11 +13,16 @@ export const useLinkEditorState = ({
const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]); const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]);
const isSearchQuery = useMemo(
() => url.length > 0 && !isValidUrl,
[url, isValidUrl],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isValidUrl) { if (isValidUrl) {
onSetLink(url); onSetLink(url, false);
} }
}, },
[url, isValidUrl, onSetLink], [url, isValidUrl, onSetLink],
@@ -29,5 +34,6 @@ export const useLinkEditorState = ({
onChange, onChange,
handleSubmit, handleSubmit,
isValidUrl, isValidUrl,
isSearchQuery,
}; };
}; };
@@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
@@ -13,17 +14,23 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const { spaceSlug, pageSlug } = useParams(); const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const { const {
data: page, data: page,
isLoading, isLoading,
isError, isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null }); } = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
const location = useLocation(); const { data: sharedPage } = useSharePageQuery({
const isShareRoute = location.pathname.startsWith("/share"); pageId: isPageMention && isShareRoute ? slugId : undefined,
});
const currentPageSlugId = extractPageSlugId(pageSlug); const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId; const isSamePage = currentPageSlugId === slugId;
@@ -39,10 +46,12 @@ export default function MentionView(props: NodeViewProps) {
} }
}; };
const sharePageTitle = sharedPage?.page?.title || label;
const shareSlugUrl = buildSharedPageUrl({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: sharePageTitle,
anchorId, anchorId,
}); });
@@ -54,21 +63,59 @@ export default function MentionView(props: NodeViewProps) {
</Text> </Text>
)} )}
{entityType === "page" && isError && ( {isPageMention && isShareRoute && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{entityType === "page" && !isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
to={ to={shareSlugUrl}
isShareRoute onClick={handleClick}
? shareSlugUrl underline="never"
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId) className={classes.pageMentionLink}
} >
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{sharePageTitle}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label, anchorId)}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{label}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)}
onClick={handleClick} onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -25,9 +25,9 @@ export default function SubpagesView(props: NodeViewProps) {
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({ const { data, isLoading, error } = useGetSidebarPagesQuery(
pageId: currentPageId, shareId ? null : { pageId: currentPageId },
}); );
const subpages = useMemo(() => { const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages // If we're in a shared context, use the shared subpages
@@ -2,6 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
max-width: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
animation: pulse 1.2s ease-in-out infinite; animation: pulse 1.2s ease-in-out infinite;
@@ -86,8 +86,9 @@ import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell"; import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala"; import scala from "highlight.js/lib/languages/scala";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts"; import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx"; import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import LinkView from "@/features/editor/components/link/link-view.tsx";
import i18n from "@/i18n.ts"; import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
@@ -176,6 +177,10 @@ export const mainExtensions = [
}), }),
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
}).extend({
addMarkView() {
return ReactMarkViewRenderer(LinkView);
},
}), }),
Superscript, Superscript,
SubScript, SubScript,
@@ -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>
); );
@@ -42,7 +42,7 @@ export const useEditorScroll = ({
return; return;
} }
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`); const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
if (dom) { if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true); resolve(true);
@@ -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";
@@ -50,7 +52,6 @@ import {
handleFileDrop, handleFileDrop,
handlePaste, handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx"; } from "@/features/editor/components/common/editor-paste-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu"; import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
@@ -67,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);
@@ -93,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(
@@ -408,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} />
@@ -418,10 +424,15 @@ export default function PageEditor({
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} /> <DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</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")}
@@ -98,12 +98,12 @@
a { a {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
@mixin light { @mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0); border-bottom: 0.07em solid var(--mantine-color-dark-0);
} }
@mixin dark { @mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2); border-bottom: 0.07em solid var(--mantine-color-dark-2);
} }
/*font-weight: 500; */ font-weight: 500;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@@ -223,13 +223,13 @@
.ProseMirror > h4, .ProseMirror > h4,
.ProseMirror > h5, .ProseMirror > h5,
.ProseMirror > h6 { .ProseMirror > h6 {
> .link-btn { > .link-btn {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
} }
> .link-btn > .link-btn-content { > .link-btn > .link-btn-content {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
@@ -241,7 +241,7 @@
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
} }
&:hover > .link-btn > .link-btn-content { &:hover > .link-btn > .link-btn-content {
opacity: 1; opacity: 1;
} }
@@ -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({
@@ -1,4 +1,4 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
import { import {
searchAttachments, searchAttachments,
searchPage, searchPage,
@@ -32,6 +32,7 @@ export function useSearchSuggestionsQuery(
staleTime: 60 * 1000, // 1min staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(queryParams), queryFn: () => searchSuggestions(queryParams),
enabled: preload || !!params.query, enabled: preload || !!params.query,
placeholderData: keepPreviousData,
}); });
} }
@@ -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,
}, },
}); });

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