Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 28347d0bfe noop audit module 2026-03-04 17:37:39 +00:00
124 changed files with 4941 additions and 7378 deletions
+39 -39
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.70.3", "version": "0.70.1",
"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": "^5.0.1", "@casl/react": "^4.0.0",
"@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.18", "@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.18", "@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.18", "@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.18", "@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.18", "@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.18", "@mantine/notifications": "^8.3.14",
"@mantine/spotlight": "^8.3.18", "@mantine/spotlight": "^8.3.14",
"@tabler/icons-react": "^3.40.0", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "5.90.17", "@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.13.6", "axios": "^1.13.5",
"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": "^25.10.1", "i18next": "^23.16.8",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^2.7.3",
"jotai": "^2.18.1", "jotai": "^2.16.2",
"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.40", "katex": "0.16.27",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0", "mermaid": "^11.12.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "1.363.1", "posthog-js": "1.345.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18", "react-clear-modal": "^2.0.17",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^1.0.7", "react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1", "react-error-boundary": "^4.1.2",
"react-helmet-async": "^3.0.0", "react-helmet-async": "^2.0.5",
"react-i18next": "^16.5.8", "react-i18next": "^15.0.1",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.12.0",
"semver": "^7.7.4", "semver": "^7.7.3",
"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.28.0", "@eslint/js": "^9.16.0",
"@tanstack/eslint-plugin-query": "^5.94.4", "@tanstack/eslint-plugin-query": "^5.62.1",
"@types/blueimp-load-image": "^5.16.6", "@types/blueimp-load-image": "^5.16.0",
"@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.8", "@types/katex": "^0.16.7",
"@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": "^6.0.0", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.28.0", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0", "globals": "^15.13.0",
"optics-ts": "^2.4.1", "optics-ts": "^2.4.1",
"postcss": "^8.5.8", "postcss": "^8.4.49",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1", "prettier": "^3.4.1",
"typescript": "^5.9.3", "typescript": "^5.7.2",
"typescript-eslint": "^8.57.1", "typescript-eslint": "^8.17.0",
"vite": "^8.0.1" "vite": "^7.2.4"
} }
} }
@@ -289,11 +289,6 @@
"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",
@@ -444,6 +439,7 @@
"Toggle space public sharing": "Toggle space public sharing", "Toggle space public sharing": "Toggle space public sharing",
"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",
@@ -625,9 +621,7 @@
"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",
"Upgrade your plan": "Upgrade your plan", "Enterprise feature": "Enterprise feature",
"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",
@@ -635,15 +629,17 @@
"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",
"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.", "View the": "View the",
"View the <anchor>API documentation</anchor> for usage details.": "View the <anchor>API documentation</anchor> for usage details.", "for usage details.": "for usage details.",
"View the <anchor>MCP documentation</anchor>.": "View the <anchor>MCP documentation</anchor>.", "for setup instructions.": "for setup instructions.",
"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",
@@ -658,12 +654,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",
"<bold>{{name}}</bold> mentioned you in a comment": "<bold>{{name}}</bold> mentioned you in a comment", "mentioned you in a comment": "mentioned you in a comment",
"<bold>{{name}}</bold> commented on a page": "<bold>{{name}}</bold> commented on a page", "commented on a page": "commented on a page",
"<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> resolved a comment", "resolved a comment": "resolved a comment",
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page", "mentioned you on a page": "mentioned you on a page",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page", "gave you edit access to a page": "gave you edit access to a page",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page", "gave you view access to a page": "gave you view access to a page",
"Today": "Today", "Today": "Today",
"Yesterday": "Yesterday", "Yesterday": "Yesterday",
"This week": "This week", "This week": "This week",
@@ -697,16 +693,5 @@
"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."
} }
-2
View File
@@ -38,7 +38,6 @@ 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();
@@ -64,7 +63,6 @@ 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,9 +21,7 @@ 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 { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { import {
prefetchApiKeyManagement, prefetchApiKeyManagement,
prefetchApiKeys, prefetchApiKeys,
@@ -41,19 +39,22 @@ 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";
type DataItem = { interface DataItem {
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
path: string; path: string;
feature?: string; isCloud?: boolean;
role?: "admin" | "owner"; isEnterprise?: boolean;
env?: "cloud" | "selfhosted"; isAdmin?: boolean;
}; isOwner?: boolean;
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
type DataGroup = { interface DataGroup {
heading: string; heading: string;
items: DataItem[]; items: DataItem[];
}; }
const groupedData: DataGroup[] = [ const groupedData: DataGroup[] = [
{ {
@@ -69,7 +70,9 @@ const groupedData: DataGroup[] = [
label: "API keys", label: "API keys",
icon: IconKey, icon: IconKey,
path: "/settings/account/api-keys", path: "/settings/account/api-keys",
feature: Feature.API_KEYS, isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -77,20 +80,26 @@ 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",
role: "admin", isCloud: true,
env: "cloud", isAdmin: true,
}, },
{ {
label: "Security & SSO", label: "Security & SSO",
icon: IconLock, icon: IconLock,
path: "/settings/security", path: "/settings/security",
feature: Feature.SECURITY_SETTINGS, isCloud: true,
role: "admin", isEnterprise: true,
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" },
@@ -99,22 +108,25 @@ const groupedData: DataGroup[] = [
label: "API management", label: "API management",
icon: IconKey, icon: IconKey,
path: "/settings/api-keys", path: "/settings/api-keys",
feature: Feature.API_KEYS, isCloud: true,
role: "admin", isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
}, },
{ {
label: "AI settings", label: "AI settings",
icon: IconSparkles, icon: IconSparkles,
path: "/settings/ai", path: "/settings/ai",
role: "admin", isAdmin: true,
}, },
{ {
label: "Audit log", label: "Audit log",
icon: IconHistory, icon: IconHistory,
path: "/settings/audit", path: "/settings/audit",
feature: Feature.AUDIT_LOGS, isEnterprise: true,
role: "owner", isOwner: true,
env: "selfhosted", isSelfhosted: true,
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -136,8 +148,7 @@ 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 [entitlements] = useAtom(entitlementAtom); const [workspace] = useAtom(workspaceAtom);
const upgradeLabel = useUpgradeLabel();
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -145,20 +156,43 @@ export default function SettingsSidebar() {
setActive(location.pathname); setActive(location.pathname);
}, [location.pathname]); }, [location.pathname]);
const hasFeature = (f: string) => const hasRoleAccess = (item: DataItem) => {
entitlements?.features?.includes(f) ?? false; if (item.isOwner) return isOwner;
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.feature) return false; if (item.showDisabledInNonEE && item.isEnterprise) {
return !hasFeature(item.feature); return !(isCloud() || workspace?.hasLicenseKey);
}
return false;
}; };
const menuItems = groupedData.map((group) => { const menuItems = groupedData.map((group) => {
@@ -191,7 +225,7 @@ export default function SettingsSidebar() {
prefetchHandler = prefetchBilling; prefetchHandler = prefetchBilling;
break; break;
case "License & Edition": case "License & Edition":
if (entitlements?.tier !== "free") { if (workspace?.hasLicenseKey) {
prefetchHandler = prefetchLicense; prefetchHandler = prefetchLicense;
} }
break; break;
@@ -246,7 +280,7 @@ export default function SettingsSidebar() {
return ( return (
<Tooltip <Tooltip
key={item.label} key={item.label}
label={upgradeLabel} label={t("Available in enterprise edition")}
position="right" position="right"
withArrow withArrow
> >
@@ -34,7 +34,6 @@ export function AutoTooltipText({
disabled={!isTruncated || !label} disabled={!isTruncated || !label}
multiline multiline
withArrow withArrow
withinPortal={false}
{...tooltipProps} {...tooltipProps}
> >
<Text <Text
@@ -1,13 +1,12 @@
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core"; import { Group, Text, Switch, MantineSize, Title } 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"; import { isCloud } from "@/lib/config.ts";
import { Feature } from "@/ee/features"; import useLicense from "@/ee/hooks/use-license.tsx";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiSearch() { export default function EnableAiSearch() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -38,8 +37,9 @@ 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 hasAccess = useHasFeature(Feature.AI); const { hasLicenseKey } = useLicense();
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,7 +56,6 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
}; };
return ( return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch <Switch
size={size} size={size}
label={label} label={label}
@@ -66,6 +65,5 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
disabled={!hasAccess} disabled={!hasAccess}
aria-label={t("Toggle AI search")} aria-label={t("Toggle AI search")}
/> />
</Tooltip>
); );
} }
@@ -1,20 +1,17 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core"; import { Group, Text, Switch } 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"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
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 = useHasFeature(Feature.AI); const hasAccess = useIsCloudEE();
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;
@@ -41,13 +38,11 @@ export default function EnableGenerativeAi() {
</Text> </Text>
</div> </div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch <Switch
defaultChecked={checked} defaultChecked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasAccess} disabled={!hasAccess}
/> />
</Tooltip>
</Group> </Group>
); );
} }
@@ -13,12 +13,10 @@ 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 { Trans, 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"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
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";
@@ -27,8 +25,7 @@ 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 = useHasFeature(Feature.MCP); const hasAccess = useIsCloudEE();
const upgradeLabel = useUpgradeLabel();
const mcpUrl = `${getAppUrl()}/mcp`; const mcpUrl = `${getAppUrl()}/mcp`;
@@ -49,7 +46,11 @@ export default function McpSettings() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{!hasAccess && ( {!hasAccess && (
<Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue"> <Alert
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.",
)} )}
@@ -63,22 +64,23 @@ 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.",
)}{" "} )}{" "}
<Trans {t("View the")}{" "}
i18nKey="View the <anchor>MCP documentation</anchor>." <Anchor
components={{ href="https://docmost.com/docs/user-guide/mcp"
anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />, target="_blank"
}} size="sm"
/> >
{t("MCP documentation")}
</Anchor>
.
</Text> </Text>
</div> </div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch <Switch
defaultChecked={checked} defaultChecked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasAccess} disabled={!hasAccess}
/> />
</Tooltip>
</Group> </Group>
{checked && ( {checked && (
@@ -87,7 +89,11 @@ export default function McpSettings() {
{t("MCP Server URL")} {t("MCP Server URL")}
</Text> </Text>
<Group gap="xs"> <Group gap="xs">
<TextInput value={mcpUrl} readOnly style={{ flex: 1 }} /> <TextInput
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<CopyButton value={mcpUrl} timeout={2000}> <CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip <Tooltip
@@ -117,36 +123,12 @@ export default function McpSettings() {
{t("Supported tools")} {t("Supported tools")}
</Text> </Text>
<List size="sm" spacing={2}> <List size="sm" spacing={2}>
<List.Item> <List.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</Text></List.Item>
<Text size="sm" c="dimmed" span> <List.Item><Text size="sm" c="dimmed" span>list_pages, list_child_pages, duplicate_page</Text></List.Item>
search_pages, get_page, create_page, update_page <List.Item><Text size="sm" c="dimmed" span>copy_page_to_space, move_page, move_page_to_space</Text></List.Item>
</Text> <List.Item><Text size="sm" c="dimmed" span>get_space, list_spaces, create_space, update_space</Text></List.Item>
</List.Item> <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>
<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>
+3 -6
View File
@@ -9,17 +9,14 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
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 = useHasFeature(Feature.AI); const hasAccess = useIsCloudEE();
const upgradeLabel = useUpgradeLabel();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -58,7 +55,7 @@ export default function AiSettings() {
{!hasAccess && ( {!hasAccess && (
<Alert <Alert
icon={<IconInfoCircle />} icon={<IconInfoCircle />}
title={upgradeLabel} title={t("Enterprise feature")}
color="blue" color="blue"
mb="lg" mb="lg"
> >
@@ -5,14 +5,12 @@ 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"; import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
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();
@@ -20,8 +18,7 @@ export default function RestrictApiToAdmins() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true, workspace?.settings?.api?.restrictToAdmins === true,
); );
const hasAccess = useHasFeature(Feature.API_KEYS); const hasAccess = useEnterpriseAccess();
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;
@@ -54,7 +51,7 @@ export default function RestrictApiToAdmins() {
<ResponsiveSettingsControl> <ResponsiveSettingsControl>
<Tooltip <Tooltip
label={upgradeLabel} label={t("Requires an enterprise license")}
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 { Trans, useTranslation } from "react-i18next"; import { 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,12 +58,11 @@ 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">
<Trans {t("View the")}{" "}
i18nKey="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>
{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 { Trans, useTranslation } from "react-i18next"; import { 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">
<Trans {t("Manage API keys for all users in the workspace.")}{" "}
i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details." {t("View the")}{" "}
components={{ <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />, {t("API documentation")}
}} </Anchor>{" "}
/> {t("for usage details.")}
</Text> </Text>
<RestrictApiToAdmins /> <RestrictApiToAdmins />
@@ -5,15 +5,3 @@ 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,21 +20,14 @@ 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";
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>({
@@ -44,13 +37,6 @@ 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);
@@ -68,19 +54,6 @@ 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> <div>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
@@ -110,38 +83,6 @@ 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>
+2 -1
View File
@@ -6,6 +6,7 @@ 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() {
@@ -56,7 +57,7 @@ export default function SsoLogin() {
/> />
)} )}
{data.authProviders.length > 0 && ( {(isCloud() || data.hasLicenseKey) && (
<> <>
<Stack align="stretch" justify="center" gap="sm"> <Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => ( {data.authProviders.map((provider) => (
@@ -1,7 +0,0 @@
import { atomWithStorage } from "jotai/utils";
import type { Entitlements } from "./entitlement.types";
export const entitlementAtom = atomWithStorage<Entitlements | null>(
"entitlements",
null,
);
@@ -1,7 +0,0 @@
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;
}
@@ -1,7 +0,0 @@
export type Tier = "free" | "standard" | "business" | "enterprise";
export type Entitlements = {
cloud: boolean;
tier: Tier;
features: string[];
};
@@ -1,11 +0,0 @@
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,
});
}
-19
View File
@@ -1,19 +0,0 @@
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',
} as const;
@@ -0,0 +1,12 @@
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
@@ -1,7 +0,0 @@
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
@@ -0,0 +1,9 @@
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;
@@ -1,16 +0,0 @@
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");
}
@@ -7,22 +7,21 @@ 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 { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
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 [entitlements] = useAtom(entitlementAtom); const [workspace] = useAtom(workspaceAtom);
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}>
{hasLicense ? t("Update license") : t("Add license")} {workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
</Button> </Button>
{hasLicense && <RemoveLicense />} {workspace?.hasLicenseKey && <RemoveLicense />}
<Modal <Modal
size="550" size="550"
@@ -60,7 +59,7 @@ 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();
} }
return ( return (
@@ -31,8 +31,7 @@ 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>
{license.licenseType === "business" ? "Business" : "Enterprise"}{" "} Enterprise {license.trial && <Badge color="green">Trial</Badge>}
{license.trial && <Badge color="green">Trial</Badge>}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
+3 -4
View File
@@ -8,11 +8,10 @@ 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 { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function License() { export default function License() {
const [entitlements] = useAtom(entitlementAtom); const [workspace] = useAtom(workspaceAtom);
const hasLicense = entitlements != null && entitlements.tier !== "free";
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
if (!isAdmin) { if (!isAdmin) {
@@ -30,7 +29,7 @@ export default function License() {
<InstallationDetails /> <InstallationDetails />
{hasLicense ? <LicenseDetails /> : <OssDetails />} {workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
</> </>
); );
} }
@@ -31,7 +31,6 @@ 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;
@@ -48,7 +47,6 @@ 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,10 +1,7 @@
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;
@@ -7,9 +7,8 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature"; import { isCloud } from "@/lib/config.ts";
import { Feature } from "@/ee/features"; import useLicense from "@/ee/hooks/use-license.tsx";
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() {
@@ -18,8 +17,7 @@ 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 canUseMfa = useHasFeature(Feature.MFA); const { hasLicenseKey } = useLicense();
const upgradeLabel = useUpgradeLabel();
const { data: mfaStatus, isLoading } = useQuery({ const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"], queryKey: ["mfa-status"],
@@ -30,6 +28,8 @@ 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={upgradeLabel} label={t("Available in enterprise edition")}
disabled={canUseMfa} disabled={canUseMfa}
> >
<Button <Button
@@ -19,8 +19,7 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
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";
@@ -34,9 +33,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 hasPagePermissions = useHasFeature(Feature.PAGE_PERMISSIONS); const isCloudEE = useIsCloudEE();
const [activeTab, setActiveTab] = useState<string | null>( const [activeTab, setActiveTab] = useState<string | null>(
hasPagePermissions ? "access" : "publish", isCloudEE ? "access" : "publish",
); );
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
@@ -52,7 +51,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const isPubliclyShared = !!share; const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } = const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && hasPagePermissions ? pageId : undefined); usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
return ( return (
<> <>
@@ -93,7 +92,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="access"> <Tabs.Panel value="access">
{!hasPagePermissions ? ( {!isCloudEE ? (
<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}>
-107
View File
@@ -1,107 +0,0 @@
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";
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 (
<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>
);
}
return (
<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>
);
}
@@ -6,9 +6,7 @@ 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"; import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
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();
@@ -33,8 +31,7 @@ function DisablePublicSharingToggle() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true, workspace?.settings?.sharing?.disabled === true,
); );
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS); const hasAccess = useEnterpriseAccess();
const upgradeLabel = useUpgradeLabel();
const applyChange = async (value: boolean) => { const applyChange = async (value: boolean) => {
try { try {
@@ -75,11 +72,15 @@ function DisablePublicSharingToggle() {
}; };
return ( return (
<Tooltip label={upgradeLabel} disabled={hasSharingControls} refProp="rootRef"> <Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch <Switch
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasSharingControls} disabled={!hasAccess}
aria-label={t("Toggle public sharing")} aria-label={t("Toggle public sharing")}
/> />
</Tooltip> </Tooltip>
@@ -1,20 +1,10 @@
import { import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
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();
@@ -43,8 +33,6 @@ 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;
@@ -61,16 +49,13 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
}; };
return ( return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch <Switch
size={size} size={size}
label={label} label={label}
labelPosition="left" labelPosition="left"
defaultChecked={checked} defaultChecked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle MFA enforcement")} aria-label={t("Toggle MFA enforcement")}
/> />
</Tooltip>
); );
} }
@@ -1,13 +1,10 @@
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core"; import { Group, Text, Switch, MantineSize } 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();
@@ -36,8 +33,6 @@ 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;
@@ -54,16 +49,13 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
}; };
return ( return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch <Switch
size={size} size={size}
label={label} label={label}
labelPosition="left" labelPosition="left"
defaultChecked={checked} defaultChecked={checked}
onChange={handleChange} onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle sso enforcement")} aria-label={t("Toggle sso enforcement")}
/> />
</Tooltip>
); );
} }
@@ -6,9 +6,6 @@ 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;
@@ -20,9 +17,6 @@ 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,
); );
@@ -74,14 +68,14 @@ export default function SpacePublicSharingToggle({
</Text> </Text>
</div> </div>
<Tooltip <Tooltip
label={!hasSharingControls ? upgradeLabel : t("Public sharing is disabled at the workspace level")} label={t("Public sharing is disabled at the workspace level")}
disabled={!isDisabled} disabled={!workspaceDisabled}
refProp="rootRef" refProp="rootRef"
> >
<Switch <Switch
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
disabled={isDisabled} disabled={workspaceDisabled}
aria-label={t("Toggle space public sharing")} aria-label={t("Toggle space public sharing")}
/> />
</Tooltip> </Tooltip>
@@ -12,18 +12,13 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature"; import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
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): { function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
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" };
} }
@@ -41,19 +36,14 @@ function retentionToDays(amount: number, unit: RetentionUnit): number {
export default function TrashRetention() { export default function TrashRetention() {
const { t } = useTranslation(); const { t } = useTranslation();
const hasRetention = useHasFeature(Feature.RETENTION); const hasAccess = useEnterpriseAccess();
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>( const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
parsed.amount, const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(
parsed.unit,
);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -73,17 +63,14 @@ export default function TrashRetention() {
setSaving(true); setSaving(true);
try { try {
const updatedWorkspace = await updateWorkspace({ const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
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: message: err?.response?.data?.message || t("Failed to update trash retention"),
err?.response?.data?.message || t("Failed to update trash retention"),
color: "red", color: "red",
}); });
const { amount, unit } = daysToRetention(currentDays); const { amount, unit } = daysToRetention(currentDays);
@@ -94,8 +81,7 @@ export default function TrashRetention() {
} }
}; };
const isDirty = const isDirty = retentionToDays(
retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1, typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit, retentionUnit,
) !== currentDays; ) !== currentDays;
@@ -107,7 +93,10 @@ 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 label={upgradeLabel} disabled={hasRetention}> <Tooltip
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}
@@ -116,7 +105,7 @@ export default function TrashRetention() {
hideControls hideControls
size="sm" size="sm"
w={60} w={60}
disabled={!hasRetention} disabled={!hasAccess}
/> />
<Select <Select
data={[ data={[
@@ -132,13 +121,13 @@ export default function TrashRetention() {
}} }}
size="sm" size="sm"
style={{ flex: 1 }} style={{ flex: 1 }}
disabled={!hasRetention} disabled={!hasAccess}
/> />
<Button <Button
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
loading={saving} loading={saving}
disabled={!hasRetention || !isDirty} disabled={!hasAccess || !isDirty}
> >
{t("Save")} {t("Save")}
</Button> </Button>
+18 -7
View File
@@ -12,15 +12,14 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature"; import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { Feature } from "@/ee/features"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM); const hasEnterpriseAccess = useEnterpriseAccess();
const hasRetention = useHasFeature(Feature.RETENTION); const isCloudEE = useIsCloudEE();
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -37,27 +36,39 @@ export default function Security() {
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && (
<>
<DisablePublicSharing /> <DisablePublicSharing />
<Divider my="lg" /> <Divider my="lg" />
</>
)}
{!isCloud() && (
<>
<TrashRetention /> <TrashRetention />
<Divider my="lg" /> <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 /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />
</>
)}
{(isCloud() || hasCustomSso) && ( {isCloudEE && (
<> <>
<AllowedDomains /> <AllowedDomains />
<Divider my="lg" /> <Divider my="lg" />
</> </>
)} )}
{hasCustomSso && ( {hasEnterpriseAccess && (
<> <>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
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, { message: "Name is required" }).max(50), name: z.string().min(1).max(50),
email: z email: z
.email({ message: "Invalid email address" }) .email()
.min(1, { message: "Email is required" }), .min(1, { message: "email is required" }),
password: z.string().min(8, { message: "Password must be at least 8 characters" }), password: z.string().min(8),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -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, getHostnameUrl } from "@/ee/utils.ts"; import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
export default function useAuth() { export default function useAuth() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,18 +52,9 @@ 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, message: err.response?.data.message,
color: "red", color: "red",
}); });
} }
@@ -101,17 +92,6 @@ 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) {
@@ -51,4 +51,3 @@ 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;
} }
@@ -24,12 +24,7 @@ function CommentActions({
</Button> </Button>
)} )}
<Button <Button size="compact-sm" loading={isLoading} onClick={onSave}>
size="compact-sm"
loading={isLoading}
onClick={onSave}
onMouseDown={(e) => e.preventDefault()}
>
{t("Save")} {t("Save")}
</Button> </Button>
</Group> </Group>
@@ -7,8 +7,7 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
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 {
@@ -45,7 +44,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 canResolve = useHasFeature(Feature.COMMENT_RESOLUTION); const isCloudEE = useIsCloudEE();
const createdAtAgo = useTimeAgo(comment.createdAt); const createdAtAgo = useTimeAgo(comment.createdAt);
useEffect(() => { useEffect(() => {
@@ -82,7 +81,7 @@ function CommentListItem({
} }
async function handleResolveComment() { async function handleResolveComment() {
if (!canResolve) return; if (!isCloudEE) return;
try { try {
const isResolved = comment.resolvedAt != null; const isResolved = comment.resolvedAt != null;
@@ -138,7 +137,7 @@ function CommentListItem({
</Text> </Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}> <div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && canResolve && ( {!comment.parentCommentId && canComment && isCloudEE && (
<ResolveComment <ResolveComment
editor={editor} editor={editor}
commentId={comment.id} commentId={comment.id}
@@ -27,9 +27,6 @@ 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();
@@ -348,7 +345,6 @@ 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);
@@ -367,14 +363,6 @@ const PageCommentInput = ({ onSave, isLoading }) => {
position: "relative", position: "relative",
}} }}
> >
<Group wrap="nowrap" align="flex-start" gap="xs">
<CustomAvatar
size="sm"
avatarUrl={currentUser?.user?.avatarUrl}
name={currentUser?.user?.name}
style={{ flexShrink: 0, marginTop: 10 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor <CommentEditor
ref={commentEditorRef} ref={commentEditorRef}
onUpdate={setContent} onUpdate={setContent}
@@ -382,15 +370,12 @@ const PageCommentInput = ({ onSave, isLoading }) => {
editable={true} editable={true}
placeholder={t("Add a comment...")} 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,16 +1,8 @@
import { ActionIcon, Menu, Tooltip } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react";
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 { useHasFeature } from "@/ee/hooks/use-feature"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type CommentMenuProps = { type CommentMenuProps = {
onEditComment: () => void; onEditComment: () => void;
@@ -27,11 +19,10 @@ function CommentMenu({
onResolveComment, onResolveComment,
canEdit = true, canEdit = true,
isResolved = false, isResolved = false,
isParentComment = false, isParentComment = false
}: CommentMenuProps) { }: CommentMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION); const isCloudEE = useIsCloudEE();
const upgradeLabel = useUpgradeLabel();
//@ts-ignore //@ts-ignore
const openDeleteModal = () => const openDeleteModal = () =>
@@ -53,34 +44,33 @@ function CommentMenu({
<Menu.Dropdown> <Menu.Dropdown>
{canEdit && ( {canEdit && (
<Menu.Item <Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
onClick={onEditComment}
leftSection={<IconEdit size={14} />}
>
{t("Edit comment")} {t("Edit comment")}
</Menu.Item> </Menu.Item>
)} )}
{isParentComment && {isParentComment && (
(canResolve ? ( isCloudEE ? (
<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={upgradeLabel} position="left"> <Tooltip label={t("Available in enterprise edition")} position="left">
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}> <Menu.Item
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}
@@ -10,5 +10,3 @@ 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, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; import { showAiMenuAtom } 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,8 +49,6 @@ 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;
@@ -60,10 +58,6 @@ 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) => {
@@ -141,7 +135,6 @@ 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;
@@ -154,6 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
}, },
}, },
@@ -161,10 +155,11 @@ 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 || showLinkMenu) return; if (showAiMenu) return;
return ( return (
<BubbleMenu <BubbleMenu
@@ -194,6 +189,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
}} }}
/> />
@@ -204,6 +200,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false); setIsColorSelectorOpen(false);
}} }}
/> />
@@ -227,7 +224,16 @@ 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}
@@ -236,6 +242,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsColorSelectorOpen(!isColorSelectorOpen); setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
}} }}
/> />
@@ -1,25 +1,66 @@
import { FC } from "react"; import { Dispatch, FC, SetStateAction, useCallback } from "react";
import { IconLink } from "@tabler/icons-react"; import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Tooltip } from "@mantine/core"; import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useSetAtom } from "jotai"; import { useEditor } from "@tiptap/react";
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";
export const LinkSelector: FC = () => { interface LinkSelectorProps {
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 setShowLinkMenu = useSetAtom(showLinkMenuAtom); const onLink = useCallback(
(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
width={300}
opened={isOpen}
trapFocus
offset={{ mainAxis: 35, crossAxis: 0 }}
withArrow
>
<Popover.Target>
<Tooltip label={t("Add link")} withArrow> <Tooltip label={t("Add link")} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" size="lg"
radius="0" radius="0"
style={{ border: "none" }} style={{
onClick={() => setShowLinkMenu(true)} border: "none",
}}
onClick={() => setIsOpen(!isOpen)}
> >
<IconLink size={16} /> <IconLink size={16} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Popover.Target>
<Popover.Dropdown>
<LinkEditorPanel onSetLink={onLink} />
</Popover.Dropdown>
</Popover>
); );
}; };
@@ -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, useEffect, useRef, useState } from "react"; import { useCallback, 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,9 +8,7 @@ import {
} from "@/features/editor/components/table/types/types.ts"; } from "@/features/editor/components/table/types/types.ts";
import { import {
ActionIcon, ActionIcon,
LoadingOverlay,
Modal, Modal,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
@@ -31,12 +29,10 @@ 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) {
@@ -45,10 +41,6 @@ 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,
@@ -139,14 +131,33 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const saveData = useCallback(async (svgXml: string) => { const handleOpen = useCallback(async () => {
if (isSavingRef.current) return; if (!editorState?.src) return;
isSavingRef.current = true;
setIsSaving(true);
try { try {
const svgString = decodeBase64ToSvgString(svgXml); 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 {
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);
@@ -168,88 +179,10 @@ 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();
return;
}
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();
}, },
}); [editor, editorState?.attachmentId, 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 (
<> <>
@@ -314,7 +247,6 @@ 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>
@@ -344,17 +276,15 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}> <Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body pos="relative"> <Modal.Body>
<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,
@@ -366,19 +296,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
saveData(data.xml).then(() => close()).catch(() => {}); handleSave(data);
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
handleClose(); close();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
}} }}
/> />
</div> </div>
@@ -2,12 +2,11 @@ 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 { useCallback, useEffect, useRef, useState } from "react"; import { 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";
@@ -15,7 +14,6 @@ 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";
@@ -23,7 +21,6 @@ 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();
@@ -33,26 +30,16 @@ 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 saveData = async (svgXml: string, updateSrc = true) => { const handleSave = async (data: EventSave) => {
if (isSavingRef.current) return; const svgString = decodeBase64ToSvgString(data.xml);
isSavingRef.current = true;
setIsSaving(true);
try {
const svgString = decodeBase64ToSvgString(svgXml);
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -66,88 +53,27 @@ export default function DrawioView(props: NodeViewProps) {
attachment = await uploadFile(drawioSVGFile, pageId); attachment = await uploadFile(drawioSVGFile, pageId);
} }
if (updateSrc) {
updateAttributes({ updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName, title: attachment.fileName,
size: attachment.fileSize, size: attachment.fileSize,
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
};
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close(); close();
return;
}
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]);
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={handleClose} fullScreen closeOnEscape={false}> <Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body pos="relative"> <Modal.Body>
<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,
@@ -159,19 +85,13 @@ export default function DrawioView(props: NodeViewProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
saveData(data.xml, true).then(() => close()).catch(() => {}); handleSave(data);
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
handleClose(); close();
}}
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, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -10,11 +10,9 @@ 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 {
@@ -54,12 +52,6 @@ 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,
@@ -155,7 +147,6 @@ 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, {
@@ -169,22 +160,15 @@ 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 saveData = useCallback(async () => { const handleSave = useCallback(async () => {
if (!excalidrawAPI || isSavingRef.current) { if (!excalidrawAPI) {
return; return;
} }
isSavingRef.current = true;
setIsSaving(true);
try {
const { exportToSvg } = await import("@excalidraw/excalidraw"); const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({ const svg = await exportToSvg({
@@ -225,56 +209,8 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
setIsSaving(false);
}
}, [editor, excalidrawAPI, editorState?.attachmentId]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close(); close();
} catch { }, [editor, excalidrawAPI, editorState?.attachmentId, close]);
// save failed, modal stays open
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
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]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData().catch(() => {});
}
}, 60_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<> <>
@@ -345,7 +281,6 @@ 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>
@@ -382,7 +317,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={handleClose} onRequestClose={close}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -397,10 +332,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}> <Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}> <Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -408,18 +343,6 @@ 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,14 +7,7 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { import { lazy, Suspense, useState } from "react";
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";
@@ -27,7 +20,6 @@ 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) => ({
@@ -50,30 +42,18 @@ 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 saveData = useCallback(async (updateSrc = true) => { const handleSave = async () => {
if (!excalidrawAPI || isSavingRef.current) { if (!excalidrawAPI) {
return; return;
} }
isSavingRef.current = true;
setIsSaving(true);
try {
const { exportToSvg } = await import("@excalidraw/excalidraw"); const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({ const svg = await exportToSvg({
@@ -106,69 +86,15 @@ export default function ExcalidrawView(props: NodeViewProps) {
attachment = await uploadFile(excalidrawSvgFile, pageId); attachment = await uploadFile(excalidrawSvgFile, pageId);
} }
if (updateSrc) {
updateAttributes({ updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName, title: attachment.fileName,
size: attachment.fileSize, size: attachment.fileSize,
attachmentId: attachment.id, 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(); close();
} catch { };
/* empty */
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
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]);
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>
@@ -179,7 +105,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={handleClose} onRequestClose={close}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -194,10 +120,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSaveAndExit} size={"compact-sm"} loading={isSaving}> <Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}> <Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -205,18 +131,6 @@ 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,
@@ -1,200 +1,36 @@
import React, { useCallback, useEffect, useRef, useState } from "react"; import React from "react";
import { import { Button, Group, TextInput } from "@mantine/core";
Group, import { IconLink } from "@tabler/icons-react";
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 { spaceSlug } = useParams(); const state = useLinkEditorState({
const { data: space } = useSpaceQuery(spaceSlug); onSetLink,
const state = useLinkEditorState({ onSetLink, initialUrl }); 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
autoFocus
/> />
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
{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,114 +1,103 @@
import { FC, useCallback, useEffect, useRef } from "react"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { BubbleMenu } from "@tiptap/react/menus"; import React, { useCallback, useState } from "react";
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 { Paper } from "@mantine/core"; import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
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";
type EditorLinkMenuProps = { export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
editor: Editor; const [showEdit, setShowEdit] = useState(false);
};
export const EditorLinkMenu: FC<EditorLinkMenuProps> = ({ editor }) => { const shouldShow = useCallback(() => {
const [showLinkMenu, setShowLinkMenu] = useAtom(showLinkMenuAtom); return editor.isActive("link");
const showLinkMenuRef = useRef(showLinkMenu); }, [editor]);
const containerRef = useRef<HTMLDivElement>(null); const editorState = useEditorState({
editor,
useEffect(() => { selector: (ctx) => {
showLinkMenuRef.current = showLinkMenu; if (!ctx.editor) {
if (showLinkMenu) { return null;
editor.commands.focus();
} }
}, [showLinkMenu, editor]); const link = ctx.editor.getAttributes("link");
return {
const focusInput = useCallback(() => { href: link.href,
requestAnimationFrame(() => { };
containerRef.current },
?.querySelector<HTMLInputElement>("input")
?.focus({ preventScroll: true });
}); });
const handleEdit = useCallback(() => {
setShowEdit(true);
}, []); }, []);
const onSetLink = useCallback( const onSetLink = useCallback(
(url: string, internal?: boolean) => { (url: string) => {
editor editor
.chain() .chain()
.focus() .focus()
.setLink({ .extendMarkRange("link")
href: internal ? url : normalizeUrl(url), .setLink({ href: 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();
setShowLinkMenu(false); setShowEdit(false);
}, },
[editor, setShowLinkMenu], [editor],
); );
useEffect(() => { const onUnsetLink = useCallback(() => {
if (!showLinkMenu) return; editor.chain().focus().extendMarkRange("link").unsetLink().run();
setShowEdit(false);
return null;
}, [editor]);
const dismiss = () => { const onShowEdit = useCallback(() => {
setShowLinkMenu(false); setShowEdit(true);
editor.commands.focus(); }, []);
editor.commands.setTextSelection(editor.state.selection.to);
};
const handleKeyDown = (e: KeyboardEvent) => { const onHideEdit = useCallback(() => {
if (e.key === "Escape") { setShowEdit(false);
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 (
<BubbleMenu <BaseBubbleMenu
editor={editor} editor={editor}
shouldShow={({ editor, state }) => { pluginKey={`link-menu`}
const { empty } = state.selection; updateDelay={0}
return (
showLinkMenuRef.current &&
editor.isEditable &&
!empty &&
isTextSelected(editor)
);
}}
options={{ options={{
placement: "bottom",
offset: 8,
onShow: focusInput,
onHide: () => { onHide: () => {
setShowLinkMenu(false); setShowEdit(false);
}, },
placement: "bottom",
offset: 5,
// zIndex: 101,
}} }}
style={{ zIndex: 198, position: "relative" }} shouldShow={shouldShow}
> >
<Paper ref={containerRef} w={320} p="sm" shadow="md" radius={6} withBorder> {showEdit ? (
<LinkEditorPanel onSetLink={onSetLink} /> <Card
</Paper> withBorder
</BubbleMenu> radius="md"
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;
@@ -0,0 +1,60 @@
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>
</>
);
};
@@ -1,578 +0,0 @@
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,102 +1,6 @@
.linkWrapper { .link {
position: relative; color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
display: inline; overflow: hidden;
} 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,5 +1,4 @@
export type LinkEditorPanelProps = { export type LinkEditorPanelProps = {
initialUrl?: string; initialUrl?: string;
onSetLink: (url: string, internal?: boolean) => void; onSetLink: (url: string, openInNewTab?: boolean) => void;
onUnsetLink?: () => void;
}; };
@@ -13,16 +13,11 @@ 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, false); onSetLink(url);
} }
}, },
[url, isValidUrl, onSetLink], [url, isValidUrl, onSetLink],
@@ -34,6 +29,5 @@ export const useLinkEditorState = ({
onChange, onChange,
handleSubmit, handleSubmit,
isValidUrl, isValidUrl,
isSearchQuery,
}; };
}; };
@@ -3,7 +3,6 @@ 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,
@@ -14,23 +13,17 @@ 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: isPageMention && !isShareRoute ? slugId : null }); } = usePageQuery({ pageId: entityType === "page" ? slugId : null });
const { data: sharedPage } = useSharePageQuery({ const location = useLocation();
pageId: isPageMention && isShareRoute ? slugId : undefined, const isShareRoute = location.pathname.startsWith("/share");
});
const currentPageSlugId = extractPageSlugId(pageSlug); const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId; const isSamePage = currentPageSlugId === slugId;
@@ -46,12 +39,10 @@ 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: sharePageTitle, pageTitle: label,
anchorId, anchorId,
}); });
@@ -63,59 +54,21 @@ export default function MentionView(props: NodeViewProps) {
</Text> </Text>
)} )}
{isPageMention && isShareRoute && ( {entityType === "page" && isError && (
<Anchor <Text component="span" c="dimmed" size="sm">
component={Link}
fw={500}
to={shareSlugUrl}
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}>
{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} {label}
</span> </Text>
</Anchor>
)} )}
{isPageMention && !isShareRoute && !isError && ( {entityType === "page" && !isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)} to={
isShareRoute
? shareSlugUrl
: 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({
shareId ? null : { pageId: currentPageId }, 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
@@ -86,9 +86,8 @@ 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, ReactMarkViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } 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";
@@ -177,10 +176,6 @@ export const mainExtensions = [
}), }),
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
}).extend({
addMarkView() {
return ReactMarkViewRenderer(LinkView);
},
}), }),
Superscript, Superscript,
SubScript, SubScript,
@@ -42,7 +42,7 @@ export const useEditorScroll = ({
return; return;
} }
const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`); const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
if (dom) { if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true); resolve(true);
@@ -50,6 +50,7 @@ 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";
@@ -66,7 +67,6 @@ 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 {
@@ -408,7 +408,6 @@ export default function PageEditor({
{editor && editorIsEditable && ( {editor && editorIsEditable && (
<div> <div>
<EditorAiMenu editor={editor} /> <EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} /> <TableCellMenu editor={editor} appendTo={menuContainerRef} />
@@ -419,6 +418,7 @@ 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>
)} )}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
@@ -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.07em solid var(--mantine-color-dark-0); border-bottom: 0.05em solid var(--mantine-color-dark-0);
} }
@mixin dark { @mixin dark {
border-bottom: 0.07em solid var(--mantine-color-dark-2); border-bottom: 0.05em solid var(--mantine-color-dark-2);
} }
font-weight: 500; /*font-weight: 500; */
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@@ -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 { Trans, useTranslation } from "react-i18next"; import { 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 getNotificationMessageKey = (): string => { const getNotificationMessage = (): string => {
switch (notification.type) { switch (notification.type) {
case "comment.user_mention": case "comment.user_mention":
return "<bold>{{name}}</bold> mentioned you in a comment"; return t("mentioned you in a comment");
case "comment.created": case "comment.created":
return "<bold>{{name}}</bold> commented on a page"; return t("commented on a page");
case "comment.resolved": case "comment.resolved":
return "<bold>{{name}}</bold> resolved a comment"; return t("resolved a comment");
case "page.user_mention": case "page.user_mention":
return "<bold>{{name}}</bold> mentioned you on a page"; return t("mentioned you on a page");
case "page.permission_granted": case "page.permission_granted":
return notification.data?.role === "writer" return notification.data?.role === "writer"
? "<bold>{{name}}</bold> gave you edit access to a page" ? t("gave you edit access to a page")
: "<bold>{{name}}</bold> gave you view access to a page"; : t("gave you view access to a page");
default: default:
return ""; return "";
} }
@@ -95,11 +95,10 @@ 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}>
<Trans <Text span fw={600}>
i18nKey={getNotificationMessageKey()} {notification.actor?.name}
values={{ name: notification.actor?.name }} </Text>{" "}
components={{ bold: <Text span fw={600} /> }} {getNotificationMessage()}
/>
</Text> </Text>
{notification.page && ( {notification.page && (
@@ -28,11 +28,9 @@ 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 } from "@/lib/config.ts"; import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
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";
@@ -84,6 +82,7 @@ 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();
@@ -94,9 +93,8 @@ 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 = useHasFeature(Feature.CONFLUENCE_IMPORT); const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT); const canUseDocx = isCloud() || workspace?.hasLicenseKey;
const upgradeLabel = useUpgradeLabel();
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) { if (!selectedFile) {
@@ -362,7 +360,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={upgradeLabel} label={t("Available in enterprise edition")}
disabled={canUseDocx} disabled={canUseDocx}
> >
<Button <Button
@@ -401,7 +399,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={upgradeLabel} label={t("Available in enterprise edition")}
disabled={canUseConfluence} disabled={canUseConfluence}
> >
<Button <Button
@@ -110,7 +110,15 @@ 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) => {
updatePageData(data); updatePage(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 { useHasFeature } from "@/ee/hooks/use-feature"; import { useLicense } from "@/ee/hooks/use-license";
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 hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const { hasLicenseKey } = useLicense();
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: !hasAttachmentIndexing, disabled: !isCloud() && !hasLicenseKey,
}, },
]; ];
@@ -11,16 +11,15 @@ 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 { useHasFeature } from "@/ee/hooks/use-feature"; import { useLicense } from "@/ee/hooks/use-license.tsx";
import { Feature } from "@/ee/features"; import { isCloud } from "@/lib/config.ts";
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 hasAiFeature = useHasFeature(Feature.AI); const { hasLicenseKey } = useLicense();
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<{
@@ -85,7 +84,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && hasAttachmentIndexing; filters.contentType === "attachment" && (hasLicenseKey || isCloud());
const resultItems = (searchResults || []).map((result) => ( const resultItems = (searchResults || []).map((result) => (
<SearchResultItem <SearchResultItem
@@ -135,7 +134,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
} }
}} }}
/> />
{isAiMode && hasAiFeature && ( {isAiMode && hasLicenseKey && (
<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 { useHasFeature } from "@/ee/hooks/use-feature"; import { useLicense } from "@/ee/hooks/use-license";
import { Feature } from "@/ee/features"; import { isCloud } from "@/lib/config";
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 hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const { hasLicenseKey } = useLicense();
const isAttachmentSearch = const isAttachmentSearch =
params.contentType === "attachment" && hasAttachmentIndexing; params.contentType === "attachment" && (isCloud() || hasLicenseKey);
const searchType = isAttachmentSearch ? "attachment" : "page"; const searchType = isAttachmentSearch ? "attachment" : "page";
return useQuery({ return useQuery({
@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query"; import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { import {
searchAttachments, searchAttachments,
searchPage, searchPage,
@@ -32,7 +32,6 @@ 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,
}); });
} }
@@ -180,7 +180,7 @@ export default function ShareShell({
<AppShell.Main> <AppShell.Main>
{children} {children}
{data && shareId && !(data.features?.length > 0) && <ShareBranding />} {data && shareId && !data.hasLicenseKey && <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 };
}; };
features?: string[]; hasLicenseKey: boolean;
} }
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[]>;
features?: string[]; hasLicenseKey: boolean;
} }
@@ -19,6 +19,7 @@ import {
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 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;
@@ -27,7 +28,8 @@ 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 showSharingToggle = !readOnly; 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);
@@ -1,4 +1,4 @@
import { useAtom, useSetAtom } from "jotai"; import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user"; import useCurrentUser from "@/features/user/hooks/use-current-user";
@@ -11,14 +11,10 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts"; import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import { useEntitlements } from "@/ee/entitlement/use-entitlements";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export function UserProvider({ children }: React.PropsWithChildren) { export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom); const [, setCurrentUser] = useAtom(currentUserAtom);
const setEntitlements = useSetAtom(entitlementAtom);
const { data, isLoading, error, isError } = useCurrentUser(); const { data, isLoading, error, isError } = useCurrentUser();
const { data: entitlements } = useEntitlements();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [, setSocket] = useAtom(socketAtom); const [, setSocket] = useAtom(socketAtom);
// fetch collab token on load // fetch collab token on load
@@ -60,12 +56,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
} }
}, [data, isLoading]); }, [data, isLoading]);
useEffect(() => {
if (entitlements) {
setEntitlements(entitlements);
}
}, [entitlements]);
if (isLoading) return <></>; if (isLoading) return <></>;
if (isError && error?.["response"]?.status === 404) { if (isError && error?.["response"]?.status === 404) {
@@ -113,7 +113,7 @@ export async function getInvitationById(data: {
export async function createWorkspace( export async function createWorkspace(
data: ISetupWorkspace, data: ISetupWorkspace,
): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> { ): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> {
const req = await api.post("/workspace/create", data); const req = await api.post("/workspace/create", data);
return req.data; return req.data;
} }
@@ -20,6 +20,7 @@ export interface IWorkspace {
emailDomains: string[]; emailDomains: string[];
memberCount?: number; memberCount?: number;
plan?: string; plan?: string;
hasLicenseKey?: boolean;
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean; aiSearch?: boolean;
generativeAi?: boolean; generativeAi?: boolean;
@@ -83,6 +84,7 @@ export interface IPublicWorkspace {
hostname: string; hostname: string;
enforceSso: boolean; enforceSso: boolean;
authProviders: IAuthProvider[]; authProviders: IAuthProvider[];
hasLicenseKey?: boolean;
} }
export interface IVersion { export interface IVersion {
@@ -0,0 +1,7 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};
-1
View File
@@ -12,7 +12,6 @@ const APP_ROUTE = {
SELECT_WORKSPACE: "/select", SELECT_WORKSPACE: "/select",
MFA_CHALLENGE: "/login/mfa", MFA_CHALLENGE: "/login/mfa",
MFA_SETUP_REQUIRED: "/login/mfa/setup", MFA_SETUP_REQUIRED: "/login/mfa/setup",
VERIFY_EMAIL: "/verify-email",
}, },
SETTINGS: { SETTINGS: {
ACCOUNT: { ACCOUNT: {
-6
View File
@@ -94,12 +94,6 @@ export function getPageIcon(icon: string, size = 18): string | ReactNode {
); );
} }
export const normalizeUrl = (url: string): string => {
if (!url) return url;
if (url.startsWith("/") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return url;
return `https://${url}`;
};
export function castToBoolean(value: unknown): boolean { export function castToBoolean(value: unknown): boolean {
if (value == null) { if (value == null) {
return false; return false;
+1 -1
View File
@@ -68,7 +68,7 @@ export default function SharedPage() {
/> />
</Container> </Container>
{data && !shareId && !(data.features?.length > 0) && <ShareBranding />} {data && !shareId && !data.hasLicenseKey && <ShareBranding />}
</div> </div>
); );
} }
+1 -15
View File
@@ -2,7 +2,7 @@ import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import * as path from "path"; import * as path from "path";
const envPath = path.resolve(process.cwd(), "..", ".."); export const envPath = path.resolve(process.cwd(), "..", "..");
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const { const {
@@ -35,20 +35,6 @@ export default defineConfig(({ mode }) => {
APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_VERSION: JSON.stringify(process.env.npm_package_version),
}, },
plugins: [react()], plugins: [react()],
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [
{ name: "vendor-mantine", test: /@mantine/ },
{ name: "vendor-mermaid", test: /mermaid|cytoscape|elkjs/ },
{ name: "vendor-excalidraw", test: /excalidraw/ },
{ name: "vendor-katex", test: /katex/ },
],
},
},
},
},
resolve: { resolve: {
alias: { alias: {
"@": "/src", "@": "/src",
+55 -56
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.70.3", "version": "0.70.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -30,123 +30,122 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.52", "@ai-sdk/google": "^3.0.29",
"@ai-sdk/openai": "^3.0.47", "@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.37", "@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-s3": "3.1014.0", "@aws-sdk/client-s3": "3.1000.0",
"@aws-sdk/lib-storage": "3.1014.0", "@aws-sdk/lib-storage": "3.1000.0",
"@aws-sdk/s3-request-presigner": "3.1014.0", "@aws-sdk/s3-request-presigner": "3.1000.0",
"@clickhouse/client": "^1.18.2", "@clickhouse/client": "^1.17.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0", "@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@keyv/redis": "^5.1.6", "@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.34", "@langchain/core": "1.1.29",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.27.1", "@modelcontextprotocol/sdk": "^1.27.1",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0", "@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.17", "@nestjs/common": "^11.1.14",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.17", "@nestjs/core": "^11.1.14",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.2", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.17", "@nestjs/platform-fastify": "^11.1.14",
"@nestjs/platform-socket.io": "^11.1.17", "@nestjs/platform-socket.io": "^11.1.14",
"@nestjs/schedule": "^6.1.1", "@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1", "@nestjs/terminus": "^11.1.1",
"@nestjs/websockets": "^11.1.17", "@nestjs/websockets": "^11.1.14",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.10", "@react-email/components": "1.0.7",
"@react-email/render": "2.0.4", "@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.134", "ai": "^6.0.86",
"ai-sdk-ollama": "^3.8.1", "ai-sdk-ollama": "^3.7.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.71.0", "bullmq": "^5.70.1",
"cache-manager": "^7.2.8", "cache-manager": "^7.2.8",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie": "^1.1.1", "cookie": "^1.1.1",
"fs-extra": "^11.3.4", "fs-extra": "^11.3.3",
"happy-dom": "20.8.4", "happy-dom": "20.1.0",
"ioredis": "^5.10.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"kysely-postgres-js": "^3.0.0", "kysely-postgres-js": "^3.0.0",
"ldapts": "^8.1.7", "ldapts": "^7.4.0",
"lib0": "^0.2.117", "lib0": "^0.2.117",
"mammoth": "^1.12.0", "mammoth": "^1.11.0",
"mime-types": "^3.0.2", "mime-types": "^2.1.35",
"msgpackr": "^1.11.9", "msgpackr": "^1.11.8",
"nanoid": "5.1.7", "nanoid": "3.3.11",
"nestjs-cls": "^6.2.0", "nestjs-cls": "^6.2.0",
"nestjs-kysely": "^3.1.2", "nestjs-kysely": "^1.2.0",
"nestjs-pino": "^4.6.1", "nestjs-pino": "^4.5.0",
"nodemailer": "^8.0.3", "nodemailer": "^7.0.12",
"openid-client": "^6.8.2", "openid-client": "^5.7.1",
"otpauth": "^9.5.0", "otpauth": "^9.4.1",
"p-limit": "^7.3.0", "p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.5.207", "pdfjs-dist": "^5.4.394",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1", "pgvector": "^0.2.1",
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"postgres": "^3.4.8", "postgres": "^3.4.8",
"postmark": "^4.0.7", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2", "sanitize-filename-ts": "1.0.2",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"stripe": "^17.7.0", "stripe": "^17.5.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"tseep": "^1.3.1", "tseep": "^1.3.1",
"typesense": "^3.0.3", "typesense": "^2.1.0",
"ws": "^8.19.0", "ws": "^8.19.0",
"yauzl": "^3.2.1", "yauzl": "^3.2.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.28.0", "@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.16", "@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.9", "@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.1.17", "@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/mime-types": "^3.0.1", "@types/mime-types": "^2.1.4",
"@types/node": "^25.5.0", "@types/node": "^22.13.4",
"@types/nodemailer": "^7.0.11", "@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.17", "@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"eslint": "^9.28.0", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.0.1",
"globals": "^17.4.0", "globals": "^15.15.0",
"jest": "^30.3.0", "jest": "^30.2.0",
"kysely-codegen": "^0.20.0", "kysely-codegen": "^0.20.0",
"prettier": "^3.8.1", "prettier": "^3.5.1",
"react-email": "5.2.10", "react-email": "5.2.8",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"ts-loader": "^9.5.4", "ts-loader": "^9.5.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.57.1" "typescript-eslint": "^8.24.1"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@@ -116,7 +116,7 @@ export class CollaborationGateway {
// Forward close events // Forward close events
client.on('close', (code: number, reason: Buffer) => { client.on('close', (code: number, reason: Buffer) => {
this.redisSync!.onSocketClose(socketId, code, reason.buffer as ArrayBuffer); this.redisSync!.onSocketClose(socketId, code, reason);
}); });
// Forward pong events for keepalive // Forward pong events for keepalive
+9
View File
@@ -91,6 +91,15 @@ export function extractBearerTokenFromHeader(
return type === 'Bearer' ? token : undefined; return type === 'Bearer' ? token : undefined;
} }
export function hasLicenseOrEE(opts: {
licenseKey: string;
plan: string;
isCloud: boolean;
}): boolean {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
}
/** /**
* Normalizes a database URL for postgres.js compatibility. * Normalizes a database URL for postgres.js compatibility.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values * - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
@@ -1,142 +0,0 @@
import { containsDomain } from './no-urls.validator';
// containsDomain returns true if value contains a domain-like pattern
// The full NoUrls validator also checks for https:// URLs separately
describe('containsDomain', () => {
describe('bare domains with real TLDs — should block', () => {
it.each([
'example.com',
'example.net',
'example.org',
'example.io',
'example.co',
'example.dev',
'example.app',
'example.me',
'example.info',
'example.tech',
'example.aero',
'example.cloud',
'example.museum',
'example.abc',
'example.uk',
'example.de',
'example.fr',
'example.ru',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domains with paths — should block', () => {
it.each([
'example.com/reset',
'example.com/reset-password',
'click example.com/page',
'go to example.net/login',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('multi-part domains — should block', () => {
it.each([
'Foo.com.net',
'Foo.com.',
'Foo.mine.net',
'Foo.mine.ne',
'sub.example.com',
'login.example.co.uk',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domain in sentence — should block', () => {
it.each([
'Reset your password at example.com',
'URGENT click example.com/reset',
'Visit example.org for details',
'go to mysite.io now',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('case insensitive — should block', () => {
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('fake TLDs — should allow', () => {
it.each([
'Foo.mine',
'Foo.blarg',
'Foo.qqq',
'Foo.zz',
'Foo.abcd',
'Foo.abcde',
'Foo.abcdef',
'Foo.abcdefg',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('too short suffix — should allow', () => {
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('multi-part with fake TLD — should allow', () => {
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('emails — should allow', () => {
it.each([
'user@example.com',
'admin@company.org',
'test@sub.domain.co.uk',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('normal names — should allow', () => {
it.each([
'John Smith',
'Dr. Smith',
'A. B. Charlie',
'John',
'Mary Jane',
"O'Brien",
'Jean-Pierre',
'José García',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('IP addresses — should allow', () => {
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
describe('edge cases — should allow', () => {
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
});
@@ -1,42 +0,0 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import * as tlds from 'tlds';
const URL_PATTERN = /https?:\/\//i;
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
export function containsDomain(value: string): boolean {
const tokens = value.split(/\s+/);
for (const token of tokens) {
if (token.includes('@')) continue;
const segments = token.split('.');
for (let i = 1; i < segments.length; i++) {
const suffix = segments[i].replace(/[^\w].*/g, '');
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
return true;
}
}
}
return false;
}
export function NoUrls(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'noUrls',
target: object.constructor,
propertyName,
options: {
message: 'Must not contain URLs or domain names',
...validationOptions,
},
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return true;
if (URL_PATTERN.test(value)) return false;
if (containsDomain(value)) return false;
return true;
},
},
});
};
}
@@ -1,4 +1,3 @@
export enum UserTokenType { export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password', FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
} }
-32
View File
@@ -1,37 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Workspace } from '@docmost/db/types/entity.types'; import { Workspace } from '@docmost/db/types/entity.types';
import { createHmac } from 'node:crypto';
export function computeEmailSignature(
email: string,
workspaceId: string,
appSecret: string,
): string {
return createHmac('sha256', appSecret)
.update(`${email.toLowerCase()}:${workspaceId}`)
.digest('hex');
}
export function throwIfEmailNotVerified(opts: {
isCloud: boolean;
emailVerifiedAt: Date | null;
email: string;
workspaceId: string;
appSecret: string;
}): void {
if (!opts.isCloud || opts.emailVerifiedAt) return;
const emailSignature = computeEmailSignature(
opts.email,
opts.workspaceId,
opts.appSecret,
);
throw new BadRequestException({
message:
'Please verify your email address. Check your inbox for the verification link.',
emailSignature,
});
}
export function validateSsoEnforcement(workspace: Workspace) { export function validateSsoEnforcement(workspace: Workspace) {
if (workspace.enforceSso) { if (workspace.enforceSso) {
@@ -7,13 +7,11 @@ import {
} from 'class-validator'; } from 'class-validator';
import { CreateUserDto } from './create-user.dto'; import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto { export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty() @IsNotEmpty()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(50)
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@@ -7,14 +7,12 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto { export class CreateUserDto {
@IsOptional() @IsOptional()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(50)
@IsString() @IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@@ -17,7 +17,6 @@ import {
isUserDisabled, isUserDisabled,
nanoIdGen, nanoIdGen,
} from '../../../common/helpers'; } from '../../../common/helpers';
import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto'; import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service'; import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -37,7 +36,6 @@ import {
AUDIT_SERVICE, AUDIT_SERVICE,
IAuditService, IAuditService,
} from '../../../integrations/audit/audit.service'; } from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -48,7 +46,6 @@ export class AuthService {
private userTokenRepo: UserTokenRepo, private userTokenRepo: UserTokenRepo,
private mailService: MailService, private mailService: MailService,
private domainService: DomainService, private domainService: DomainService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -72,14 +69,6 @@ export class AuthService {
throw new UnauthorizedException(errorMessage); throw new UnauthorizedException(errorMessage);
} }
throwIfEmailNotVerified({
isCloud: this.environmentService.isCloud(),
emailVerifiedAt: user.emailVerifiedAt,
email: user.email,
workspaceId,
appSecret: this.environmentService.getAppSecret(),
});
user.lastLoginAt = new Date(); user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId); await this.userRepo.updateLastLogin(user.id, workspaceId);
@@ -258,14 +247,6 @@ export class AuthService {
template: emailTemplate, template: emailTemplate,
}); });
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
await this.userRepo.updateUser(
{ emailVerifiedAt: new Date() },
user.id,
workspace.id,
);
}
// Check if user has MFA enabled or workspace enforces MFA // Check if user has MFA enabled or workspace enforces MFA
const userHasMfa = user?.['mfa']?.isEnabled || false; const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false; const workspaceEnforcesMfa = workspace.enforceMfa || false;
@@ -4,7 +4,6 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { import {
JwtApiKeyPayload, JwtApiKeyPayload,
@@ -97,7 +96,7 @@ export class TokenService {
apiKeyId: string; apiKeyId: string;
user: User; user: User;
workspaceId: string; workspaceId: string;
expiresIn?: StringValue | number; expiresIn?: string | number;
}): Promise<string> { }): Promise<string> {
const { apiKeyId, user, workspaceId, expiresIn } = opts; const { apiKeyId, user, workspaceId, expiresIn } = opts;
if (isUserDisabled(user)) { if (isUserDisabled(user)) {
+1 -2
View File
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import type { StringValue } from 'ms';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { TokenService } from './services/token.service'; import { TokenService } from './services/token.service';
@@ -11,7 +10,7 @@ import { TokenService } from './services/token.service';
return { return {
secret: environmentService.getAppSecret(), secret: environmentService.getAppSecret(),
signOptions: { signOptions: {
expiresIn: environmentService.getJwtTokenExpiresIn() as StringValue, expiresIn: environmentService.getJwtTokenExpiresIn(),
issuer: 'Docmost', issuer: 'Docmost',
}, },
}; };
@@ -91,15 +91,9 @@ export class SearchService {
return { items: [] }; return { items: [] };
} }
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
if (isRestricted) {
return { items: [] };
}
const pageIdsToSearch = []; const pageIdsToSearch = [];
if (share.includeSubPages) { if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendantsExcludingRestricted( const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId, share.pageId,
{ {
includeContent: false, includeContent: false,
+13 -10
View File
@@ -28,7 +28,8 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { LicenseCheckService } from '../../integrations/environment/license-check.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { hasLicenseOrEE } from '../../common/helpers';
import { AuditEvent, AuditResource } from '../../common/events/audit-events'; import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import { import {
AUDIT_SERVICE, AUDIT_SERVICE,
@@ -44,7 +45,7 @@ export class ShareController {
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo, private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService, private readonly pageAccessService: PageAccessService,
private readonly licenseCheckService: LicenseCheckService, private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -80,10 +81,11 @@ export class ShareController {
return { return {
...shareData, ...shareData,
features: this.licenseCheckService.resolveFeatures( hasLicenseKey: hasLicenseOrEE({
workspace.licenseKey, licenseKey: workspace.licenseKey,
workspace.plan, isCloud: this.environmentService.isCloud(),
), plan: workspace.plan,
}),
}; };
} }
@@ -257,10 +259,11 @@ export class ShareController {
return { return {
...treeData, ...treeData,
features: this.licenseCheckService.resolveFeatures( hasLicenseKey: hasLicenseOrEE({
workspace.licenseKey, licenseKey: workspace.licenseKey,
workspace.plan, isCloud: this.environmentService.isCloud(),
), plan: workspace.plan,
}),
}; };
} }
} }
@@ -139,10 +139,10 @@ export class SpaceService {
}); });
if ( if (
!this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan) !this.licenseCheckService.isValidEELicense(workspace.licenseKey)
) { ) {
throw new ForbiddenException( throw new ForbiddenException(
'This feature requires a valid license', 'This feature requires a valid enterprise license',
); );
} }
} }
@@ -37,6 +37,7 @@ export class UserController {
const workspaceInfo = { const workspaceInfo = {
...rest, ...rest,
memberCount, memberCount,
hasLicenseKey: Boolean(licenseKey),
}; };
return { user: authUser, workspace: workspaceInfo }; return { user: authUser, workspace: workspaceInfo };

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