Compare commits

...

29 Commits

Author SHA1 Message Date
Philipinho 2d6e8fd287 fix 2026-03-26 19:43:34 +00:00
Philipinho 237fd79971 rename current device property 2026-03-26 19:40:25 +00:00
Philipinho 15e5b05c7f don't cache index 2026-03-26 18:56:27 +00:00
Philipinho 7b3572a285 cleanup 2026-03-26 18:55:58 +00:00
Philipinho 3940c259e8 license 2026-03-26 18:33:12 +00:00
Philipinho 32446d1320 cleanup 2026-03-26 17:57:06 +00:00
Philipinho da7bb9a07f Merge branch 'main' into sessions 2026-03-26 17:29:28 +00:00
Philipinho 2648d7bea3 WIP 2026-03-26 17:29:25 +00:00
Philipinho 4e8f533b91 override 2026-03-26 16:48:33 +00:00
Philipinho 7b0d8fe140 override 2026-03-26 16:46:40 +00:00
Philipinho 2f92278a9d sync 2026-03-26 16:35:05 +00:00
Philipinho 53608eae35 clean up ws 2026-03-26 13:59:17 +00:00
Philipinho 0e4a1e7419 enum validation 2026-03-26 00:41:38 +00:00
Philipinho c6d2f0c6cc user session management 2026-03-26 00:21:59 +00:00
Philipinho 9125996e97 sync 2026-03-25 10:08:36 +00:00
Philip Okugbe fa4872e89e fix(deps): package updates (#2041)
* update
* overrides
* override
* fix page update mutation
* fix
* cleanup
* loader
* fix excalidraw package
* override
* fix regex
2026-03-25 10:07:01 +00:00
Philipinho 6d6f3a8a8e merge commit 2026-03-24 10:52:09 +00:00
Philip Okugbe 975b4dcaab feat: auth pages layout (#2042)
* auth pages layout
* exclude home route from redirect
* fix margin
2026-03-22 16:40:50 +00:00
Philip Okugbe 6683c515cf fix: make codeblock language detection performant (#2032)
* fix: make codeblock language detection performant
* lint
2026-03-17 20:40:22 +00:00
Philipinho cc5c800238 0.70.3 2026-03-17 14:29:09 +00:00
Philipinho cfaee93af9 fix 2026-03-17 14:28:22 +00:00
Philipinho 74eddb0638 v0.70.2 2026-03-16 13:49:50 +00:00
Philipinho 7c83a9d4f0 update dompurify 2026-03-16 13:49:20 +00:00
Philipinho 2678c4e279 fix 2026-03-16 00:32:30 +00:00
Philipinho b0bde4b375 feat: replace link popover with dedicated bubble menu 2026-03-16 00:26:03 +00:00
Philipinho 724e37d5b7 revert 2026-03-15 23:03:32 +00:00
Philipinho 33184e9d8d sync 2026-03-15 22:07:26 +00:00
Philip Okugbe 7520c329d0 fix notion importer (#2027)
* fix notion importer

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

* fix translations

* refactor

* fix

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

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