Compare commits

...

19 Commits

Author SHA1 Message Date
Philipinho d2b8d2077a fix 2026-03-15 18:21:39 +00:00
Philipinho d7d14c2acf Merge branch 'main' into feature-flag 2026-03-15 17:16:08 +00:00
Philipinho 236a63dadc sync 2026-03-15 17:09:29 +00:00
Philip Okugbe 89b94e5d19 feat: refactor link menu (#2025)
* link markview - WIP

* WIP

* feat: refactor links

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

* fix image preview on upload

* fix image loading
2026-03-04 11:51:43 +00:00
122 changed files with 2664 additions and 911 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.70.0", "version": "0.70.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -289,6 +289,11 @@
"Save & Exit": "Save & Exit", "Save & Exit": "Save & Exit",
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram", "Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
"Paste link": "Paste link", "Paste link": "Paste link",
"Paste link or search pages": "Paste link or search pages",
"Link to web page": "Link to web page",
"Recents": "Recents",
"Page or URL": "Page or URL",
"Link title": "Link title",
"Edit link": "Edit link", "Edit link": "Edit link",
"Remove link": "Remove link", "Remove link": "Remove link",
"Add link": "Add link", "Add link": "Add link",
@@ -439,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",
@@ -621,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",
@@ -629,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",
@@ -654,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",
@@ -693,5 +697,16 @@
"Failed to update trash retention": "Failed to update trash retention", "Failed to update trash retention": "Failed to update trash retention",
"Removed page restriction": "Removed page restriction", "Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission", "Added page permission": "Added page permission",
"Removed page permission": "Removed page permission" "Removed page permission": "Removed page permission",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
"Check your email": "Check your email",
"We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.",
"We sent a verification link to your email.": "We sent a verification link to your email.",
"Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.",
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces."
} }
+2
View File
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -63,6 +64,7 @@ export default function App() {
<> <>
<Route path={"/create"} element={<CreateWorkspace />} /> <Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} /> <Route path={"/select"} element={<CloudLogin />} />
<Route path={"/verify-email"} element={<VerifyEmail />} />
</> </>
)} )}
@@ -21,7 +21,9 @@ import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { import {
prefetchApiKeyManagement, prefetchApiKeyManagement,
prefetchApiKeys, prefetchApiKeys,
@@ -39,22 +41,19 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
interface DataItem { type DataItem = {
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
path: string; path: string;
isCloud?: boolean; feature?: string;
isEnterprise?: boolean; role?: "admin" | "owner";
isAdmin?: boolean; env?: "cloud" | "selfhosted";
isOwner?: boolean; };
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
interface DataGroup { type DataGroup = {
heading: string; heading: string;
items: DataItem[]; items: DataItem[];
} };
const groupedData: DataGroup[] = [ const groupedData: DataGroup[] = [
{ {
@@ -70,9 +69,7 @@ const groupedData: DataGroup[] = [
label: "API keys", label: "API keys",
icon: IconKey, icon: IconKey,
path: "/settings/account/api-keys", path: "/settings/account/api-keys",
isCloud: true, feature: Feature.API_KEYS,
isEnterprise: true,
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -80,26 +77,20 @@ const groupedData: DataGroup[] = [
heading: "Workspace", heading: "Workspace",
items: [ items: [
{ label: "General", icon: IconSettings, path: "/settings/workspace" }, { label: "General", icon: IconSettings, path: "/settings/workspace" },
{ { label: "Members", icon: IconUsers, path: "/settings/members" },
label: "Members",
icon: IconUsers,
path: "/settings/members",
},
{ {
label: "Billing", label: "Billing",
icon: IconCoin, icon: IconCoin,
path: "/settings/billing", path: "/settings/billing",
isCloud: true, role: "admin",
isAdmin: true, env: "cloud",
}, },
{ {
label: "Security & SSO", label: "Security & SSO",
icon: IconLock, icon: IconLock,
path: "/settings/security", path: "/settings/security",
isCloud: true, feature: Feature.SECURITY_SETTINGS,
isEnterprise: true, role: "admin",
isAdmin: true,
showDisabledInNonEE: true,
}, },
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
@@ -108,25 +99,22 @@ const groupedData: DataGroup[] = [
label: "API management", label: "API management",
icon: IconKey, icon: IconKey,
path: "/settings/api-keys", path: "/settings/api-keys",
isCloud: true, feature: Feature.API_KEYS,
isEnterprise: true, role: "admin",
isAdmin: true,
showDisabledInNonEE: true,
}, },
{ {
label: "AI settings", label: "AI settings",
icon: IconSparkles, icon: IconSparkles,
path: "/settings/ai", path: "/settings/ai",
isAdmin: true, role: "admin",
}, },
{ {
label: "Audit log", label: "Audit log",
icon: IconHistory, icon: IconHistory,
path: "/settings/audit", path: "/settings/audit",
isEnterprise: true, feature: Feature.AUDIT_LOGS,
isOwner: true, role: "owner",
isSelfhosted: true, env: "selfhosted",
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -148,7 +136,8 @@ export default function SettingsSidebar() {
const [active, setActive] = useState(location.pathname); const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation(); const { goBack } = useSettingsNavigation();
const { isAdmin, isOwner } = useUserRole(); const { isAdmin, isOwner } = useUserRole();
const [workspace] = useAtom(workspaceAtom); const [entitlements] = useAtom(entitlementAtom);
const upgradeLabel = useUpgradeLabel();
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -156,43 +145,20 @@ export default function SettingsSidebar() {
setActive(location.pathname); setActive(location.pathname);
}, [location.pathname]); }, [location.pathname]);
const hasRoleAccess = (item: DataItem) => { const hasFeature = (f: string) =>
if (item.isOwner) return isOwner; entitlements?.features?.includes(f) ?? false;
if (item.isAdmin) return isAdmin;
const canShowItem = (item: DataItem) => {
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false;
return true; return true;
}; };
const canShowItem = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) {
if (item.isSelfhosted && isCloud()) return false;
return hasRoleAccess(item);
}
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return hasRoleAccess(item);
}
if (item.isCloud) {
return isCloud() ? hasRoleAccess(item) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? hasRoleAccess(item) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? hasRoleAccess(item) : false;
}
return hasRoleAccess(item);
};
const isItemDisabled = (item: DataItem) => { const isItemDisabled = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) { if (!item.feature) return false;
return !(isCloud() || workspace?.hasLicenseKey); return !hasFeature(item.feature);
}
return false;
}; };
const menuItems = groupedData.map((group) => { const menuItems = groupedData.map((group) => {
@@ -225,7 +191,7 @@ export default function SettingsSidebar() {
prefetchHandler = prefetchBilling; prefetchHandler = prefetchBilling;
break; break;
case "License & Edition": case "License & Edition":
if (workspace?.hasLicenseKey) { if (entitlements?.tier !== "free") {
prefetchHandler = prefetchLicense; prefetchHandler = prefetchLicense;
} }
break; break;
@@ -280,7 +246,7 @@ export default function SettingsSidebar() {
return ( return (
<Tooltip <Tooltip
key={item.label} key={item.label}
label={t("Available in enterprise edition")} label={upgradeLabel}
position="right" position="right"
withArrow withArrow
> >
@@ -34,6 +34,7 @@ export function AutoTooltipText({
disabled={!isTruncated || !label} disabled={!isTruncated || !label}
multiline multiline
withArrow withArrow
withinPortal={false}
{...tooltipProps} {...tooltipProps}
> >
<Text <Text
@@ -1,12 +1,13 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core"; import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import useLicense from "@/ee/hooks/use-license.tsx"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiSearch() { export default function EnableAiSearch() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,9 +38,8 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search); const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -56,14 +56,16 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
}; };
return ( return (
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
size={size} <Switch
label={label} size={size}
labelPosition="left" label={label}
defaultChecked={checked} labelPosition="left"
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
aria-label={t("Toggle AI search")} disabled={!hasAccess}
/> aria-label={t("Toggle AI search")}
/>
</Tooltip>
); );
} }
@@ -1,17 +1,20 @@
import { Group, Text, Switch } from "@mantine/core"; import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableGenerativeAi() { export default function EnableGenerativeAi() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative); const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -38,11 +41,13 @@ export default function EnableGenerativeAi() {
</Text> </Text>
</div> </div>
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
defaultChecked={checked} <Switch
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
/> disabled={!hasAccess}
/>
</Tooltip>
</Group> </Group>
); );
} }
@@ -13,10 +13,12 @@ import {
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts"; import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx"; import { CopyButton } from "@/components/common/copy-button.tsx";
@@ -25,7 +27,8 @@ export default function McpSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp); const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const mcpUrl = `${getAppUrl()}/mcp`; const mcpUrl = `${getAppUrl()}/mcp`;
@@ -46,11 +49,7 @@ export default function McpSettings() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{!hasAccess && ( {!hasAccess && (
<Alert <Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
>
{t( {t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)} )}
@@ -64,23 +63,22 @@ export default function McpSettings() {
{t( {t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
)}{" "} )}{" "}
{t("View the")}{" "} <Trans
<Anchor i18nKey="View the <anchor>MCP documentation</anchor>."
href="https://docmost.com/docs/user-guide/mcp" components={{
target="_blank" anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />,
size="sm" }}
> />
{t("MCP documentation")}
</Anchor>
.
</Text> </Text>
</div> </div>
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
defaultChecked={checked} <Switch
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
/> disabled={!hasAccess}
/>
</Tooltip>
</Group> </Group>
{checked && ( {checked && (
@@ -89,11 +87,7 @@ export default function McpSettings() {
{t("MCP Server URL")} {t("MCP Server URL")}
</Text> </Text>
<Group gap="xs"> <Group gap="xs">
<TextInput <TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<CopyButton value={mcpUrl} timeout={2000}> <CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip <Tooltip
@@ -123,12 +117,36 @@ export default function McpSettings() {
{t("Supported tools")} {t("Supported tools")}
</Text> </Text>
<List size="sm" spacing={2}> <List size="sm" spacing={2}>
<List.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</Text></List.Item> <List.Item>
<List.Item><Text size="sm" c="dimmed" span>list_pages, list_child_pages, duplicate_page</Text></List.Item> <Text size="sm" c="dimmed" span>
<List.Item><Text size="sm" c="dimmed" span>copy_page_to_space, move_page, move_page_to_space</Text></List.Item> search_pages, get_page, create_page, update_page
<List.Item><Text size="sm" c="dimmed" span>get_space, list_spaces, create_space, update_space</Text></List.Item> </Text>
<List.Item><Text size="sm" c="dimmed" span>get_comments, create_comment, update_comment</Text></List.Item> </List.Item>
<List.Item><Text size="sm" c="dimmed" span>search_attachments, list_workspace_members, get_current_user</Text></List.Item> <List.Item>
<Text size="sm" c="dimmed" span>
list_pages, list_child_pages, duplicate_page
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
copy_page_to_space, move_page, move_page_to_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_space, list_spaces, create_space, update_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_comments, create_comment, update_comment
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
search_attachments, list_workspace_members, get_current_user
</Text>
</List.Item>
</List> </List>
</div> </div>
</div> </div>
+6 -3
View File
@@ -9,14 +9,17 @@ import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx"; import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core"; import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() { export default function AiSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -55,7 +58,7 @@ export default function AiSettings() {
{!hasAccess && ( {!hasAccess && (
<Alert <Alert
icon={<IconInfoCircle />} icon={<IconInfoCircle />}
title={t("Enterprise feature")} title={upgradeLabel}
color="blue" color="blue"
mb="lg" mb="lg"
> >
@@ -5,12 +5,14 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { import {
ResponsiveSettingsRow, ResponsiveSettingsRow,
ResponsiveSettingsContent, ResponsiveSettingsContent,
ResponsiveSettingsControl, ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row"; } from "@/components/ui/responsive-settings-row";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function RestrictApiToAdmins() { export default function RestrictApiToAdmins() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,7 +20,8 @@ export default function RestrictApiToAdmins() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true, workspace?.settings?.api?.restrictToAdmins === true,
); );
const hasAccess = useEnterpriseAccess(); const hasAccess = useHasFeature(Feature.API_KEYS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -51,7 +54,7 @@ export default function RestrictApiToAdmins() {
<ResponsiveSettingsControl> <ResponsiveSettingsControl>
<Tooltip <Tooltip
label={t("Requires an enterprise license")} label={upgradeLabel}
disabled={hasAccess} disabled={hasAccess}
refProp="rootRef" refProp="rootRef"
> >
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core"; import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config"; import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -58,11 +58,12 @@ export default function UserApiKeys() {
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
{t("View the")}{" "} <Trans
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm"> i18nKey="View the <anchor>API documentation</anchor> for usage details."
{t("API documentation")} components={{
</Anchor>{" "} anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
{t("for usage details.")} }}
/>
</Text> </Text>
{mcpEnabled && canCreate && ( {mcpEnabled && canCreate && (
@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core"; import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config"; import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table"; import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -56,12 +56,12 @@ export default function WorkspaceApiKeys() {
<SettingsTitle title={t("API management")} /> <SettingsTitle title={t("API management")} />
<Text size="sm" c="dimmed" mb="md"> <Text size="sm" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace.")}{" "} <Trans
{t("View the")}{" "} i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details."
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm"> components={{
{t("API documentation")} anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
</Anchor>{" "} }}
{t("for usage details.")} />
</Text> </Text>
<RestrictApiToAdmins /> <RestrictApiToAdmins />
@@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined"); const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data; return req.data;
} }
export async function findWorkspacesByEmail(email: string): Promise<void> {
await api.post("/workspace/find-by-email", { email });
}
export async function verifyEmail(data: { token: string }): Promise<void> {
await api.post("/workspace/verify-email", data);
}
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
await api.post("/workspace/resend-verification", data);
}
@@ -20,14 +20,21 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx"; import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts"; import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
const formSchema = z.object({ const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }), hostname: z.string().min(1, { message: "subdomain is required" }),
}); });
const findWorkspaceSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
});
export function CloudLoginForm() { export function CloudLoginForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery(); const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({ const form = useForm<any>({
@@ -37,6 +44,13 @@ export function CloudLoginForm() {
}, },
}); });
const findForm = useForm<any>({
validate: zod4Resolver(findWorkspaceSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: { hostname: string }) { async function onSubmit(data: { hostname: string }) {
setIsLoading(true); setIsLoading(true);
@@ -54,6 +68,19 @@ export function CloudLoginForm() {
setIsLoading(false); setIsLoading(false);
} }
async function onFindSubmit(data: { email: string }) {
setIsFindLoading(true);
try {
await findWorkspacesByEmail(data.email);
setFindEmailSent(true);
} catch {
findForm.setFieldError("email", "An error occurred. Please try again.");
}
setIsFindLoading(false);
}
return ( return (
<div> <div>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
@@ -83,6 +110,38 @@ export function CloudLoginForm() {
{t("Continue")} {t("Continue")}
</Button> </Button>
</form> </form>
<Divider my="lg" label="or" labelPosition="center" />
{findEmailSent ? (
<Text ta="center" size="sm" c="dimmed">
{t("We've sent you an email with your associated workspaces.")}
</Text>
) : (
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
<Text fw={600} mb="xs">
{t("Find your workspaces")}
</Text>
<TextInput
type="email"
placeholder="name@company.com"
description={t(
"We'll send a list of your workspaces to this email.",
)}
withErrorStyles={false}
{...findForm.getInputProps("email")}
/>
<Button
type="submit"
fullWidth
mt="md"
variant="light"
loading={isFindLoading}
>
{t("Send")}
</Button>
</form>
)}
</Box> </Box>
</Container> </Container>
+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");
}
@@ -7,21 +7,22 @@ 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"
@@ -59,7 +60,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
async function handleSubmit(data: { licenseKey: string }) { async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey); await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset(); form.reset();
onClose(); onClose?.();
} }
return ( return (
@@ -31,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>
+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;
@@ -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
@@ -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}>
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Container, Title, Text, Button, Box } from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import {
verifyEmail,
resendVerificationEmail,
} from "@/ee/cloud/service/cloud-service.ts";
import { notifications } from "@mantine/notifications";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
export default function VerifyEmail() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const rawEmail = searchParams.get("email");
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
const sig = searchParams.get("sig");
const [isResending, setIsResending] = useState(false);
const [resent, setResent] = useState(false);
useEffect(() => {
if (token) {
handleVerify(token);
}
}, [token]);
async function handleVerify(verifyToken: string) {
try {
await verifyEmail({ token: verifyToken });
navigate(APP_ROUTE.HOME);
} catch (err) {
notifications.show({
message: t("Verification failed. The link may have expired."),
color: "red",
});
navigate(APP_ROUTE.AUTH.LOGIN);
}
}
async function handleResend() {
if (!email || !sig) return;
setIsResending(true);
try {
await resendVerificationEmail({ email, sig });
setResent(true);
} catch {
notifications.show({
message: t("Failed to resend verification email. Please try again."),
color: "red",
});
}
setIsResending(false);
}
if (token) {
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Verifying your email")}
</Title>
<Text ta="center" c="dimmed">
{t("Please wait...")}
</Text>
</Box>
</Container>
);
}
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Check your email")}
</Title>
<Text ta="center" c="dimmed" mb="md">
{email
? t("We sent a verification link to {{email}}.", { email })
: t("We sent a verification link to your email.")}
</Text>
<Text ta="center" size="sm" c="dimmed" mb="lg">
{t("Click the link to verify your email and access your workspace.")}
</Text>
{email && sig && !resent && (
<Button
fullWidth
variant="light"
onClick={handleResend}
loading={isResending}
>
{t("Resend verification email")}
</Button>
)}
{resent && (
<Text ta="center" size="sm" c="dimmed">
{t("Verification email sent. Please check your inbox.")}
</Text>
)}
</Box>
</Container>
);
}
@@ -6,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" />
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({ const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(), workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1).max(50), name: z.string().min(1, { message: "Name is required" }).max(50),
email: z email: z
.email() .email({ message: "Invalid email address" })
.min(1, { message: "email is required" }), .min(1, { message: "Email is required" }),
password: z.string().min(8), password: z.string().min(8, { message: "Password must be at least 8 characters" }),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils"; import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts"; import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() { export default function useAuth() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,9 +52,18 @@ export default function useAuth() {
} }
} catch (err) { } catch (err) {
setIsLoading(false); setIsLoading(false);
console.log(err);
const message = err.response?.data?.message;
if (isCloud() && message?.includes("verify your email")) {
const sig = err.response?.data?.emailSignature;
navigate(
`${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`,
);
return;
}
notifications.show({ notifications.show({
message: err.response?.data.message, message,
color: "red", color: "red",
}); });
} }
@@ -92,6 +101,17 @@ export default function useAuth() {
try { try {
if (isCloud()) { if (isCloud()) {
const res = await createWorkspace(data); const res = await createWorkspace(data);
if (res?.requiresEmailVerification) {
const hostname = res?.workspace?.hostname;
if (hostname) {
window.location.href =
getHostnameUrl(hostname) +
`/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`;
}
return;
}
const hostname = res?.workspace?.hostname; const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken; const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) { if (hostname && exchangeToken) {
@@ -50,4 +50,5 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
export async function getCollabToken(): Promise<ICollabToken> { export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token"); const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data; return req.data;
} }
@@ -24,7 +24,12 @@ function CommentActions({
</Button> </Button>
)} )}
<Button size="compact-sm" loading={isLoading} onClick={onSave}> <Button
size="compact-sm"
loading={isLoading}
onClick={onSave}
onMouseDown={(e) => e.preventDefault()}
>
{t("Save")} {t("Save")}
</Button> </Button>
</Group> </Group>
@@ -7,7 +7,8 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions"; import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu"; import CommentMenu from "@/features/comment/components/comment-menu";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import ResolveComment from "@/ee/comment/components/resolve-comment"; import ResolveComment from "@/ee/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { import {
@@ -44,7 +45,7 @@ function CommentListItem({
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const isCloudEE = useIsCloudEE(); const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const createdAtAgo = useTimeAgo(comment.createdAt); const createdAtAgo = useTimeAgo(comment.createdAt);
useEffect(() => { useEffect(() => {
@@ -81,7 +82,7 @@ function CommentListItem({
} }
async function handleResolveComment() { async function handleResolveComment() {
if (!isCloudEE) return; if (!canResolve) return;
try { try {
const isResolved = comment.resolvedAt != null; const isResolved = comment.resolvedAt != null;
@@ -137,7 +138,7 @@ function CommentListItem({
</Text> </Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}> <div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && isCloudEE && ( {!comment.parentCommentId && canComment && canResolve && (
<ResolveComment <ResolveComment
editor={editor} editor={editor}
commentId={comment.id} commentId={comment.id}
@@ -27,6 +27,9 @@ import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
function CommentListWithTabs() { function CommentListWithTabs() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -345,6 +348,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin(); const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null); const commentEditorRef = useRef(null);
const [currentUser] = useAtom(currentUserAtom);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
onSave(null, content); onSave(null, content);
@@ -363,19 +367,30 @@ const PageCommentInput = ({ onSave, isLoading }) => {
position: "relative", position: "relative",
}} }}
> >
<CommentEditor <Group wrap="nowrap" align="flex-start" gap="xs">
ref={commentEditorRef} <CustomAvatar
onUpdate={setContent} size="sm"
onSave={handleSave} avatarUrl={currentUser?.user?.avatarUrl}
editable={true} name={currentUser?.user?.name}
placeholder={t("Add a comment...")} style={{ flexShrink: 0, marginTop: 10 }}
/> />
<div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
</div>
</Group>
{focused && ( {focused && (
<ActionIcon <ActionIcon
variant="filled" variant="filled"
radius="xl" radius="xl"
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
loading={isLoading} loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }} style={{ position: "absolute", right: 8, bottom: 30 }}
> >
@@ -1,8 +1,16 @@
import { ActionIcon, Menu, Tooltip } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react"; import {
IconDots,
IconEdit,
IconTrash,
IconCircleCheck,
IconCircleCheckFilled,
} from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type CommentMenuProps = { type CommentMenuProps = {
onEditComment: () => void; onEditComment: () => void;
@@ -13,16 +21,17 @@ type CommentMenuProps = {
isParentComment?: boolean; isParentComment?: boolean;
}; };
function CommentMenu({ function CommentMenu({
onEditComment, onEditComment,
onDeleteComment, onDeleteComment,
onResolveComment, onResolveComment,
canEdit = true, canEdit = true,
isResolved = false, isResolved = false,
isParentComment = false isParentComment = false,
}: CommentMenuProps) { }: CommentMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCloudEE = useIsCloudEE(); const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const upgradeLabel = useUpgradeLabel();
//@ts-ignore //@ts-ignore
const openDeleteModal = () => const openDeleteModal = () =>
@@ -44,33 +53,34 @@ function CommentMenu({
<Menu.Dropdown> <Menu.Dropdown>
{canEdit && ( {canEdit && (
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}> <Menu.Item
onClick={onEditComment}
leftSection={<IconEdit size={14} />}
>
{t("Edit comment")} {t("Edit comment")}
</Menu.Item> </Menu.Item>
)} )}
{isParentComment && ( {isParentComment &&
isCloudEE ? ( (canResolve ? (
<Menu.Item <Menu.Item
onClick={onResolveComment} onClick={onResolveComment}
leftSection={ leftSection={
isResolved ? isResolved ? (
<IconCircleCheckFilled size={14} /> : <IconCircleCheckFilled size={14} />
) : (
<IconCircleCheck size={14} /> <IconCircleCheck size={14} />
)
} }
> >
{isResolved ? t("Re-open comment") : t("Resolve comment")} {isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item> </Menu.Item>
) : ( ) : (
<Tooltip label={t("Available in enterprise edition")} position="left"> <Tooltip label={upgradeLabel} position="left">
<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}
@@ -4,6 +4,7 @@ import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react"; import { useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state"; import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx"; 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";
interface LinkSelectorProps { interface LinkSelectorProps {
@@ -19,12 +20,12 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const onLink = useCallback( const onLink = useCallback(
(url: string) => { (url: string, internal?: boolean) => {
setIsOpen(false); setIsOpen(false);
editor editor
.chain() .chain()
.focus() .focus()
.setLink({ href: url }) .setLink({ href: internal ? url : normalizeUrl(url), internal: !!internal } as any)
.command(({ tr }) => { .command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to)); tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true; return true;
@@ -36,11 +37,12 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
return ( return (
<Popover <Popover
width={300} width={320}
opened={isOpen} opened={isOpen}
trapFocus trapFocus
offset={{ mainAxis: 35, crossAxis: 0 }} offset={{ mainAxis: 35, crossAxis: 0 }}
withArrow withArrow
shadow="md"
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Add link")} withArrow> <Tooltip label={t("Add link")} withArrow>
@@ -58,7 +60,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown p="sm">
<LinkEditorPanel onSetLink={onLink} /> <LinkEditorPanel onSetLink={onLink} />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -9,6 +9,7 @@ import {
import { import {
ActionIcon, ActionIcon,
Modal, Modal,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
@@ -29,10 +30,12 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -41,6 +44,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -131,33 +136,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const handleOpen = useCallback(async () => { const saveData = useCallback(async (svgXml: string) => {
if (!editorState?.src) return; if (isSavingRef.current) return;
isSavingRef.current = true;
try { try {
const url = getFileUrl(editorState.src); const svgString = decodeBase64ToSvgString(svgXml);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -179,10 +164,85 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, editorState?.attachmentId]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close(); close();
}, return;
[editor, editorState?.attachmentId, close], }
);
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
});
}, [close, t]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
isDirtyRef.current = false;
open();
}
}, [editorState?.src, open]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 60_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
return ( return (
<> <>
@@ -276,7 +336,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body>
@@ -285,6 +345,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -296,13 +357,19 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
}} }}
/> />
</div> </div>
@@ -6,7 +6,7 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl } from "@/lib/config.ts"; import { getDrawioUrl } from "@/lib/config.ts";
@@ -14,6 +14,7 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
@@ -21,6 +22,7 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx"; import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
export default function DrawioView(props: NodeViewProps) { export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,42 +32,108 @@ export default function DrawioView(props: NodeViewProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
open(); open();
}; };
const handleSave = async (data: EventSave) => { const saveData = async (svgXml: string, updateSrc = true) => {
const svgString = decodeBase64ToSvgString(data.xml); if (isSavingRef.current) return;
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore isSavingRef.current = true;
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; try {
if (attachmentId) { const svgString = decodeBase64ToSvgString(svgXml);
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); const fileName = "diagram.drawio.svg";
} else { const drawioSVGFile = await svgStringToFile(svgString, fileName);
attachment = await uploadFile(drawioSVGFile, pageId);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
};
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 30_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body>
@@ -74,6 +142,7 @@ export default function DrawioView(props: NodeViewProps) {
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -85,13 +154,19 @@ export default function DrawioView(props: NodeViewProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml, true).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data, false).catch(() => {});
}} }}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -10,9 +10,11 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Group, Group,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { import {
@@ -52,6 +54,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}); });
const [excalidrawData, setExcalidrawData] = useState<any>(null); const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -160,57 +166,109 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
} }
}, [editorState?.src, open]); }, [editorState?.src, open]);
const handleSave = useCallback(async () => { const saveData = useCallback(async () => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); const attachmentId = editorState?.attachmentId;
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId); let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, excalidrawAPI, editorState?.attachmentId]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
// save failed, modal stays open
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
editor.commands.updateAttributes("excalidraw", { modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}, [editor, excalidrawAPI, editorState?.attachmentId, close]); if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData().catch(() => {});
}
}, 60_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<> <>
@@ -317,7 +375,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -332,10 +390,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -343,6 +401,18 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -7,7 +7,14 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { lazy, Suspense, useState } from "react"; import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib"; import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
@@ -20,6 +27,7 @@ import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { modals } from "@mantine/modals";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({ import("@excalidraw/excalidraw").then((module) => ({
@@ -42,59 +50,122 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
}; };
const handleSave = async () => { const saveData = useCallback(async (updateSrc = true) => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else { let attachment: IAttachment = null;
attachment = await uploadFile(excalidrawSvgFile, pageId); if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [excalidrawAPI, editor, attachmentId, updateAttributes]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
/* empty */
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData(false).catch(() => {});
}
}, 30_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
@@ -105,7 +176,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -120,10 +191,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -131,6 +202,18 @@ export default function ExcalidrawView(props: NodeViewProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -2,6 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
max-width: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
animation: pulse 1.2s ease-in-out infinite; animation: pulse 1.2s ease-in-out infinite;
@@ -1,36 +1,199 @@
import React from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button, Group, TextInput } from "@mantine/core"; import {
import { IconLink } from "@tabler/icons-react"; Group,
ScrollArea,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { IconFileDescription, IconLink, IconWorld } from "@tabler/icons-react";
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx"; import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts"; import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
import clsx from "clsx";
import classes from "./link.module.css";
export const LinkEditorPanel = ({ export const LinkEditorPanel = ({
onSetLink, onSetLink,
initialUrl, initialUrl,
onUnsetLink,
}: LinkEditorPanelProps) => { }: LinkEditorPanelProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const state = useLinkEditorState({ const { spaceSlug } = useParams();
onSetLink, const { data: space } = useSpaceQuery(spaceSlug);
initialUrl, const state = useLinkEditorState({ onSetLink, initialUrl });
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: suggestion } = useSearchSuggestionsQuery({
query: state.isSearchQuery ? state.url : "",
includeUsers: false,
includePages: true,
spaceId: space?.id,
limit: state.isSearchQuery ? 10 : 5,
preload: true,
}); });
const pages: Partial<IPage>[] = suggestion?.pages ?? [];
useEffect(() => {
setSelectedIndex(0);
}, [pages.length]);
const selectPage = useCallback(
(page: Partial<IPage>) => {
const url = buildPageUrl(
page.space?.slug || spaceSlug,
page.slugId,
page.title,
);
onSetLink(url, true);
},
[onSetLink, spaceSlug],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const hasUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery);
const total = (hasUrlItem ? 1 : 0) + (state.isValidUrl ? 0 : pages.length);
if (total === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, total - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (hasUrlItem && selectedIndex === 0) {
onSetLink(state.url, false);
} else {
const pageIndex = hasUrlItem ? selectedIndex - 1 : selectedIndex;
if (pageIndex >= 0 && pageIndex < pages.length) {
selectPage(pages[pageIndex]);
}
}
}
},
[pages, selectedIndex, selectPage, state.isValidUrl, state.isSearchQuery, state.url, onSetLink],
);
useEffect(() => {
viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
const showPages = pages.length > 0 && !state.isValidUrl;
const showUrlItem = state.url.length > 0 && (state.isValidUrl || state.isSearchQuery);
const showDropdown = showPages || showUrlItem;
return ( return (
<div> <div>
<form onSubmit={state.handleSubmit}> <form onSubmit={state.handleSubmit}>
<Group gap="xs" style={{ flex: 1 }} wrap="nowrap"> <TextInput
<TextInput leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
leftSection={<IconLink size={16} />} classNames={{ input: classes.linkInput }}
variant="filled" placeholder={t("Paste link or search pages")}
placeholder={t("Paste link")} value={state.url}
value={state.url} onChange={state.onChange}
onChange={state.onChange} onKeyDown={handleKeyDown}
/> autoFocus
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}> />
{t("Save")}
</Button>
</Group>
</form> </form>
{showDropdown && (
<>
{!state.isSearchQuery && !state.isValidUrl && (
<Text c="dimmed" size="xs" fw={600} px="sm" pt={10} pb={4}>
{t("Recents")}
</Text>
)}
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={300}
scrollbars="y"
scrollbarSize={6}
mt={state.url.length > 0 ? 8 : 0}
styles={{ content: { minWidth: 0 } }}
>
{showUrlItem && (
<UnstyledButton
data-item-index={0}
onClick={() => onSetLink(state.url, false)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: selectedIndex === 0,
})}
>
<Group gap={10} wrap="nowrap" align="flex-start">
<span className={classes.pageIcon}>
<IconWorld size={18} stroke={1.5} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate lh={1.3}>
{state.url}
</Text>
<Text size="xs" c="dimmed" lh={1.4}>
{t("Link to web page")}
</Text>
</div>
</Group>
</UnstyledButton>
)}
{!state.isValidUrl && pages.map((page, index) => {
const itemIndex = showUrlItem ? index + 1 : index;
return (
<UnstyledButton
data-item-index={itemIndex}
key={page.id || index}
onClick={() => selectPage(page)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: itemIndex === selectedIndex,
})}
>
<Group gap={10} wrap="nowrap" align="flex-start">
<span className={classes.pageIcon}>
{page.icon || <IconFileDescription size={18} stroke={1.5} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<AutoTooltipText size="sm" fw={500} truncate lh={1.3}>
{page.title || t("Untitled")}
</AutoTooltipText>
{page.space?.name && (
<AutoTooltipText size="xs" c="dimmed" truncate lh={1.4}>
{page.space.name}
</AutoTooltipText>
)}
</div>
</Group>
</UnstyledButton>
);
})}
</ScrollArea.Autosize>
</>
)}
{onUnsetLink && (
<UnstyledButton
onClick={onUnsetLink}
className={classes.removeLink}
>
<Text size="sm" c="red">
{t("Remove link")}
</Text>
</UnstyledButton>
)}
</div> </div>
); );
}; };
@@ -1,103 +0,0 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback, useState } from "react";
import { TextSelection } from "@tiptap/pm/state";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false);
const shouldShow = useCallback(() => {
return editor.isActive("link");
}, [editor]);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => {
setShowEdit(true);
}, []);
const onSetLink = useCallback(
(url: string) => {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowEdit(false);
},
[editor],
);
const onUnsetLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setShowEdit(false);
return null;
}, [editor]);
const onShowEdit = useCallback(() => {
setShowEdit(true);
}, []);
const onHideEdit = useCallback(() => {
setShowEdit(false);
}, []);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`link-menu`}
updateDelay={0}
options={{
onHide: () => {
setShowEdit(false);
},
placement: "bottom",
offset: 5,
// zIndex: 101,
}}
shouldShow={shouldShow}
>
{showEdit ? (
<Card
withBorder
radius="md"
padding="xs"
bg="var(--mantine-color-body)"
>
<LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card>
) : (
<LinkPreviewPanel
url={editorState?.href}
onClear={onUnsetLink}
onEdit={handleEdit}
/>
)}
</BaseBubbleMenu>
);
}
export default LinkMenu;
@@ -1,60 +0,0 @@
import {
Tooltip,
ActionIcon,
Card,
Divider,
Anchor,
Flex,
} from "@mantine/core";
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./link.module.css";
export type LinkPreviewPanelProps = {
url: string;
onEdit: () => void;
onClear: () => void;
};
export const LinkPreviewPanel = ({
onClear,
onEdit,
url,
}: LinkPreviewPanelProps) => {
const { t } = useTranslation();
return (
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
<Flex align="center">
<Tooltip label={url}>
<Anchor
href={url}
target="_blank"
rel="noopener noreferrer"
className={classes.link}
>
{url}
</Anchor>
</Tooltip>
<Flex align="center">
<Divider mx={4} orientation="vertical" />
<Tooltip label={t("Edit link")}>
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Remove link")}>
<ActionIcon onClick={onClear} variant="subtle" color="red">
<IconLinkOff size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
</Card>
</>
);
};
@@ -0,0 +1,583 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import {
IconFileDescription,
IconCopy,
IconExternalLink,
IconLinkOff,
IconPencil,
IconWorld,
} from "@tabler/icons-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { notifications } from "@mantine/notifications";
import {
Divider,
Group,
Popover,
Text,
TextInput,
ActionIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./link.module.css";
import { useTranslation } from "react-i18next";
import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
export const normalizeUrl = (url: string): string => {
if (!url) return url;
if (url.startsWith("/") || /^(\S+):(\/\/)?\S+$/.test(url)) return url;
return `https://${url}`;
};
const parseInternalLink = (
href: string,
internalAttr?: boolean,
): { isInternal: boolean; slugId: string | null; label: string } => {
if (!href) return { isInternal: !!internalAttr, slugId: null, label: "" };
const match = INTERNAL_LINK_REGEX.exec(href);
if (!match) {
if (internalAttr) return { isInternal: true, slugId: null, label: href };
return { isInternal: false, slugId: null, label: href };
}
const isExternal = match[2] && match[2] !== window.location.host;
const slug = match[5];
const slugId = extractPageSlugId(slug);
const namePart = slug.split("-").slice(0, -1).join("-");
return {
isInternal: !isExternal,
slugId,
label: namePart || slug,
};
};
export default function LinkView(props: MarkViewProps) {
const { mark, editor } = props;
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
const { shareId, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
>("closed");
const [linkTitle, setLinkTitle] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [showSearch, setShowSearch] = useState(false);
const lastOpenState = useRef<"preview" | "edit">("preview");
const wrapperRef = useRef<HTMLSpanElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const isEditable = editor.isEditable;
const {
isInternal,
slugId,
label: linkLabel,
} = parseInternalLink(href, mark.attrs.internal);
const isPopoverVisible = popoverState !== "closed";
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
const pageTitle = isShareRoute
? sharedPageData?.page?.title
: linkedPage?.title;
const pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const getLinkPos = useCallback((): number | null => {
if (!wrapperRef.current) return null;
try {
return editor.view.posAtDOM(wrapperRef.current, 0);
} catch {
return null;
}
}, [editor]);
const handleUpdateLinkTitle = useCallback(
(newTitle: string) => {
if (!newTitle) return;
const pos = getLinkPos();
if (pos === null) return;
const { state } = editor;
const resolved = state.doc.resolve(pos);
const node = resolved.nodeAfter;
if (!node?.isText) return;
const linkMark = node.marks.find(
(m) => m.type.name === "link" && m.attrs.href === href,
);
if (!linkMark || node.text === newTitle) return;
const from = pos;
const to = pos + node.nodeSize;
const { tr } = state;
tr.insertText(newTitle, from, to);
tr.addMark(from, from + newTitle.length, linkMark);
editor.view.dispatch(tr);
},
[editor, href, getLinkPos],
);
const handleEditLink = useCallback(
(url: string, internal?: boolean) => {
const normalizedUrl = internal ? url : normalizeUrl(url);
const pos = getLinkPos();
if (pos === null) {
setPopoverState("closed");
return;
}
const { state } = editor;
const resolved = state.doc.resolve(pos);
const node = resolved.nodeAfter;
if (!node?.isText) {
setPopoverState("closed");
return;
}
const linkMark = node.marks.find(
(m) => m.type.name === "link" && m.attrs.href === href,
);
if (linkMark) {
const from = pos;
const to = pos + node.nodeSize;
const { tr } = state;
tr.removeMark(from, to, linkMark.type);
tr.addMark(
from,
to,
linkMark.type.create({ href: normalizedUrl, internal: !!internal }),
);
editor.view.dispatch(tr);
}
setPopoverState("closed");
},
[editor, href, getLinkPos],
);
useEffect(() => {
if (popoverState === "edit") {
const text = wrapperRef.current?.querySelector("a")?.textContent || "";
setLinkTitle(text);
setLinkUrl(href);
pendingTitleRef.current = null;
requestAnimationFrame(() => titleInputRef.current?.focus());
}
if (popoverState === "closed") {
if (pendingTitleRef.current !== null) {
handleUpdateLinkTitle(pendingTitleRef.current);
pendingTitleRef.current = null;
}
setShowSearch(false);
}
}, [popoverState, href, isInternal, handleUpdateLinkTitle]);
useEffect(() => {
if (popoverState !== "closed") {
lastOpenState.current = popoverState;
}
}, [popoverState]);
useEffect(() => {
if (!isPopoverVisible) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (
wrapperRef.current?.contains(target) ||
dropdownRef.current?.contains(target)
) {
return;
}
setPopoverState("closed");
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setPopoverState("closed");
}
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("keydown", handleEscape, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("keydown", handleEscape, true);
};
}, [isPopoverVisible]);
const handleNavigate = useCallback(() => {
if (!href) return;
if (isInternal) {
let targetPath = href;
let anchor = "";
try {
const url = new URL(href);
targetPath = url.pathname;
anchor = url.hash.slice(1);
} catch {
if (href.includes("#")) {
[targetPath, anchor] = href.split("#");
}
}
if (anchor) {
const currentPageSlugId = extractPageSlugId(pageSlug);
if (!slugId || currentPageSlugId === slugId) {
const element =
document.querySelector(`[id="${anchor}"]`) ||
document.querySelector(`[data-id="${anchor}"]`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`${location.pathname}#${anchor}`, { replace: true });
return;
}
}
}
if (isShareRoute && slugId) {
const sharedUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
pageTitle: pageTitle,
anchorId: anchor || undefined,
});
navigate(sharedUrl);
} else {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
}
} else {
window.open(
sanitizeUrl(normalizeUrl(href)),
"_blank",
"noopener,noreferrer",
);
}
}, [
href,
navigate,
location.pathname,
isInternal,
isShareRoute,
slugId,
shareId,
pageTitle,
pageSlug,
]);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isEditable) {
setPopoverState("preview");
} else {
handleNavigate();
}
},
[handleNavigate, isEditable],
);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const fullUrl = sanitizeUrl(
isInternal ? `${window.location.origin}${href}` : href,
);
copyToClipboard(fullUrl);
notifications.show({
message: t("Link copied"),
});
setPopoverState("closed");
},
[href, isInternal, t],
);
const handleRemoveLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setPopoverState("closed");
}, [editor]);
const displayHref = sanitizeUrl(
isInternal
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
);
const linkTitleInput = (
<>
<Text size="xs" fw={600} c="dimmed" mt="sm" mb={4}>
{t("Link title")}
</Text>
<TextInput
ref={titleInputRef}
classNames={{ input: classes.linkInput }}
value={linkTitle}
onChange={(e) => {
const val = e.currentTarget.value;
setLinkTitle(val);
pendingTitleRef.current = val;
const anchor = wrapperRef.current?.querySelector("a");
if (anchor && val) {
const walker = document.createTreeWalker(
anchor,
NodeFilter.SHOW_TEXT,
);
const textNode = walker.nextNode();
if (textNode) {
const view = editor.view as any;
view.domObserver.stop();
textNode.nodeValue = val;
view.domObserver.start();
}
}
}}
onBlur={() => {
if (pendingTitleRef.current !== null) {
handleUpdateLinkTitle(pendingTitleRef.current);
pendingTitleRef.current = null;
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUpdateLinkTitle(linkTitle);
pendingTitleRef.current = null;
setPopoverState("closed");
}
}}
size="sm"
/>
</>
);
return (
<Popover
opened={isPopoverVisible}
width={activeView === "edit" ? 320 : undefined}
position="bottom"
withArrow
shadow="md"
trapFocus={false}
closeOnClickOutside={false}
>
<Popover.Target>
<span
ref={wrapperRef}
className={classes.linkWrapper}
onClick={handleClick}
>
<a
href={displayHref}
spellCheck={false}
onClick={(e) => e.preventDefault()}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
>
<MarkViewContent />
</a>
</span>
</Popover.Target>
<Popover.Dropdown
ref={dropdownRef}
p={activeView === "edit" ? "sm" : 6}
onMouseDown={(e) => e.stopPropagation()}
>
{activeView === "edit" ? (
<>
<Text size="xs" fw={600} c="dimmed" mb={4}>
{t("Page or URL")}
</Text>
{isInternal ? (
!showSearch ? (
<>
<UnstyledButton
className={classes.linkChip}
onClick={() => setShowSearch(true)}
>
<IconFileDescription
size={16}
stroke={1.5}
color="var(--mantine-color-dimmed)"
style={{ flexShrink: 0 }}
/>
<Text size="sm" fw={500} truncate>
{pageTitle || linkTitle}
</Text>
</UnstyledButton>
{linkTitleInput}
<Divider my="xs" />
<UnstyledButton
onClick={handleRemoveLink}
className={classes.removeLink}
>
<Group gap={8}>
<IconLinkOff size={16} stroke={1.5} />
<Text size="sm">{t("Remove link")}</Text>
</Group>
</UnstyledButton>
</>
) : (
<LinkEditorPanel
onSetLink={handleEditLink}
onUnsetLink={handleRemoveLink}
/>
)
) : (
<>
<TextInput
leftSection={
<IconWorld
size={16}
stroke={1.5}
color="var(--mantine-color-dimmed)"
/>
}
classNames={{ input: classes.linkInput }}
value={linkUrl}
onChange={(e) => setLinkUrl(e.currentTarget.value)}
onBlur={() => {
if (linkUrl && linkUrl !== href) {
handleEditLink(linkUrl, false);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (linkUrl && linkUrl !== href) {
handleEditLink(linkUrl, false);
}
}
}}
size="sm"
/>
{linkTitleInput}
<Divider my="xs" />
<UnstyledButton
onClick={handleRemoveLink}
className={classes.removeLink}
>
<Group gap={8}>
<IconLinkOff size={16} stroke={1.5} />
<Text size="sm">{t("Remove link")}</Text>
</Group>
</UnstyledButton>
</>
)}
</>
) : (
<Group gap={4} wrap="nowrap">
<Group
component="a"
//@ts-ignore
href={displayHref}
target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
gap={6}
wrap="nowrap"
style={{
cursor: "pointer",
maxWidth: 250,
textDecoration: "none",
color: "inherit",
userSelect: "none",
}}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
handleNavigate();
}}
>
{isInternal ? (
<IconFileDescription size={18} color="gray" />
) : (
<IconExternalLink size={18} color="gray" />
)}
<Text size="sm" truncate fw={500}>
{isInternal ? pageTitle || linkLabel : href}
</Text>
</Group>
<Divider orientation="vertical" />
<Tooltip label={t("Edit link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setShowSearch(false);
setPopoverState("edit");
}}
>
<IconPencil size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Copy link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopy(e);
}}
>
<IconCopy size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Remove link")} withArrow withinPortal={false}>
<ActionIcon
variant="subtle"
color="gray"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveLink();
}}
>
<IconLinkOff size={18} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,6 +1,102 @@
.link { .linkWrapper {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); position: relative;
overflow: hidden; display: inline;
text-overflow: ellipsis; }
white-space: nowrap;
} .linkInput {
border: 1.5px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: transparent;
&:focus {
border-color: light-dark(
var(--mantine-color-blue-4),
var(--mantine-color-blue-6)
);
box-shadow: 0 0 0 1px
light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-6));
}
}
.pageIcon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--mantine-color-dimmed);
font-size: 16px;
margin-top: 2px;
}
.searchItem {
width: 100%;
padding: 7px 4px;
color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
}
.selectedSearchItem {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-gray-light);
}
}
.linkChip {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
cursor: pointer;
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-dark-5);
}
&:hover {
@mixin light {
background: var(--mantine-color-gray-2);
}
@mixin dark {
background: var(--mantine-color-dark-4);
}
}
}
.removeLink {
width: 100%;
padding: 4px;
border-radius: var(--mantine-radius-sm);
&:hover {
@mixin light {
background: var(--mantine-color-gray-1);
}
@mixin dark {
background: var(--mantine-color-dark-5);
}
}
}
@@ -1,4 +1,5 @@
export type LinkEditorPanelProps = { export type LinkEditorPanelProps = {
initialUrl?: string; initialUrl?: string;
onSetLink: (url: string, openInNewTab?: boolean) => void; onSetLink: (url: string, internal?: boolean) => void;
onUnsetLink?: () => void;
}; };
@@ -13,11 +13,16 @@ export const useLinkEditorState = ({
const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]); const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]);
const isSearchQuery = useMemo(
() => url.length > 0 && !isValidUrl,
[url, isValidUrl],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isValidUrl) { if (isValidUrl) {
onSetLink(url); onSetLink(url, false);
} }
}, },
[url, isValidUrl, onSetLink], [url, isValidUrl, onSetLink],
@@ -29,5 +34,6 @@ export const useLinkEditorState = ({
onChange, onChange,
handleSubmit, handleSubmit,
isValidUrl, isValidUrl,
isSearchQuery,
}; };
}; };
@@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
@@ -13,17 +14,23 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const { spaceSlug, pageSlug } = useParams(); const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const { const {
data: page, data: page,
isLoading, isLoading,
isError, isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null }); } = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
const location = useLocation(); const { data: sharedPage } = useSharePageQuery({
const isShareRoute = location.pathname.startsWith("/share"); pageId: isPageMention && isShareRoute ? slugId : undefined,
});
const currentPageSlugId = extractPageSlugId(pageSlug); const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId; const isSamePage = currentPageSlugId === slugId;
@@ -39,10 +46,12 @@ export default function MentionView(props: NodeViewProps) {
} }
}; };
const sharePageTitle = sharedPage?.page?.title || label;
const shareSlugUrl = buildSharedPageUrl({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: sharePageTitle,
anchorId, anchorId,
}); });
@@ -54,21 +63,59 @@ export default function MentionView(props: NodeViewProps) {
</Text> </Text>
)} )}
{entityType === "page" && isError && ( {isPageMention && isShareRoute && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{entityType === "page" && !isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
to={ to={shareSlugUrl}
isShareRoute onClick={handleClick}
? shareSlugUrl underline="never"
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId) className={classes.pageMentionLink}
} >
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{sharePageTitle}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label, anchorId)}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{label}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)}
onClick={handleClick} onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -25,9 +25,9 @@ export default function SubpagesView(props: NodeViewProps) {
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({ const { data, isLoading, error } = useGetSidebarPagesQuery(
pageId: currentPageId, shareId ? null : { pageId: currentPageId },
}); );
const subpages = useMemo(() => { const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages // If we're in a shared context, use the shared subpages
@@ -2,6 +2,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
max-width: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
animation: pulse 1.2s ease-in-out infinite; animation: pulse 1.2s ease-in-out infinite;
@@ -86,8 +86,9 @@ import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell"; import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala"; import scala from "highlight.js/lib/languages/scala";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts"; import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx"; import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import LinkView from "@/features/editor/components/link/link-view.tsx";
import i18n from "@/i18n.ts"; import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
@@ -176,6 +177,10 @@ export const mainExtensions = [
}), }),
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
}).extend({
addMarkView() {
return ReactMarkViewRenderer(LinkView);
},
}), }),
Superscript, Superscript,
SubScript, SubScript,
@@ -42,7 +42,7 @@ export const useEditorScroll = ({
return; return;
} }
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`); const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
if (dom) { if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true); resolve(true);
@@ -50,7 +50,6 @@ import {
handleFileDrop, handleFileDrop,
handlePaste, handlePaste,
} from "@/features/editor/components/common/editor-paste-handler.tsx"; } from "@/features/editor/components/common/editor-paste-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu"; import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
@@ -418,7 +417,6 @@ export default function PageEditor({
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} /> <DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} /> <ColumnsMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div> </div>
)} )}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />} {showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
@@ -98,12 +98,12 @@
a { a {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
@mixin light { @mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0); border-bottom: 0.07em solid var(--mantine-color-dark-0);
} }
@mixin dark { @mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2); border-bottom: 0.07em solid var(--mantine-color-dark-2);
} }
/*font-weight: 500; */ font-weight: 500;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }
@@ -223,13 +223,13 @@
.ProseMirror > h4, .ProseMirror > h4,
.ProseMirror > h5, .ProseMirror > h5,
.ProseMirror > h6 { .ProseMirror > h6 {
> .link-btn { > .link-btn {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
} }
> .link-btn > .link-btn-content { > .link-btn > .link-btn-content {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
@@ -241,7 +241,7 @@
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
} }
&:hover > .link-btn > .link-btn-content { &:hover > .link-btn > .link-btn-content {
opacity: 1; opacity: 1;
} }
@@ -12,7 +12,7 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types"; import { INotification } from "../types/notification.types";
import { useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useState } from "react"; import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query"; import { useMarkReadMutation } from "../queries/notification-query";
@@ -36,20 +36,20 @@ export function NotificationItem({
const isUnread = !notification.readAt; const isUnread = !notification.readAt;
const getNotificationMessage = (): string => { const getNotificationMessageKey = (): string => {
switch (notification.type) { switch (notification.type) {
case "comment.user_mention": case "comment.user_mention":
return t("mentioned you in a comment"); return "<bold>{{name}}</bold> mentioned you in a comment";
case "comment.created": case "comment.created":
return t("commented on a page"); return "<bold>{{name}}</bold> commented on a page";
case "comment.resolved": case "comment.resolved":
return t("resolved a comment"); return "<bold>{{name}}</bold> resolved a comment";
case "page.user_mention": case "page.user_mention":
return t("mentioned you on a page"); return "<bold>{{name}}</bold> mentioned you on a page";
case "page.permission_granted": case "page.permission_granted":
return notification.data?.role === "writer" return notification.data?.role === "writer"
? t("gave you edit access to a page") ? "<bold>{{name}}</bold> gave you edit access to a page"
: t("gave you view access to a page"); : "<bold>{{name}}</bold> gave you view access to a page";
default: default:
return ""; return "";
} }
@@ -95,10 +95,11 @@ export function NotificationItem({
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={2}> <Text size="sm" lineClamp={2}>
<Text span fw={600}> <Trans
{notification.actor?.name} i18nKey={getNotificationMessageKey()}
</Text>{" "} values={{ name: notification.actor?.name }}
{getNotificationMessage()} components={{ bold: <Text span fw={600} /> }}
/>
</Text> </Text>
{notification.page && ( {notification.page && (
@@ -28,9 +28,11 @@ import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx"; import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts"; import { getFileImportSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts"; import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@@ -82,7 +84,6 @@ interface ImportFormatSelection {
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const { t } = useTranslation(); const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const [workspace] = useAtom(workspaceAtom);
const [fileTaskId, setFileTaskId] = useState<string | null>(null); const [fileTaskId, setFileTaskId] = useState<string | null>(null);
const emit = useQueryEmit(); const emit = useQueryEmit();
@@ -93,8 +94,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const confluenceFileRef = useRef<() => void>(null); const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null); const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
const canUseDocx = isCloud() || workspace?.hasLicenseKey; const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
const upgradeLabel = useUpgradeLabel();
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) { if (!selectedFile) {
@@ -360,7 +362,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseDocx} disabled={canUseDocx}
> >
<Button <Button
@@ -399,7 +401,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseConfluence} disabled={canUseConfluence}
> >
<Button <Button
@@ -22,9 +22,9 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
@@ -42,7 +42,7 @@ export function SearchSpotlightFilters({
isAiMode = false, isAiMode = false,
}: SearchSpotlightFiltersProps) { }: SearchSpotlightFiltersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>( const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null, spaceId || null,
); );
@@ -87,7 +87,7 @@ export function SearchSpotlightFilters({
{ {
value: "attachment", value: "attachment",
label: t("Attachments"), label: t("Attachments"),
disabled: !isCloud() && !hasLicenseKey, disabled: !hasAttachmentIndexing,
}, },
]; ];
@@ -11,15 +11,16 @@ import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts"; import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx"; import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx"; import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { isCloud } from "@/lib/config.ts"; import { Feature } from "@/ee/features";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
} }
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300); const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
@@ -84,7 +85,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && (hasLicenseKey || isCloud()); filters.contentType === "attachment" && hasAttachmentIndexing;
const resultItems = (searchResults || []).map((result) => ( const resultItems = (searchResults || []).map((result) => (
<SearchResultItem <SearchResultItem
@@ -134,7 +135,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
} }
}} }}
/> />
{isAiMode && hasLicenseKey && ( {isAiMode && hasAiFeature && (
<Button <Button
size="xs" size="xs"
leftSection={<IconSparkles size={16} />} leftSection={<IconSparkles size={16} />}
@@ -8,8 +8,8 @@ import {
IPageSearch, IPageSearch,
IPageSearchParams, IPageSearchParams,
} from "@/features/search/types/search.types"; } from "@/features/search/types/search.types";
import { useLicense } from "@/ee/hooks/use-license"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { isCloud } from "@/lib/config"; import { Feature } from "@/ee/features";
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch; export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
@@ -21,10 +21,10 @@ export function useUnifiedSearch(
params: UseUnifiedSearchParams, params: UseUnifiedSearchParams,
enabled: boolean = true, enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> { ): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const isAttachmentSearch = const isAttachmentSearch =
params.contentType === "attachment" && (isCloud() || hasLicenseKey); params.contentType === "attachment" && hasAttachmentIndexing;
const searchType = isAttachmentSearch ? "attachment" : "page"; const searchType = isAttachmentSearch ? "attachment" : "page";
return useQuery({ return useQuery({
@@ -1,4 +1,4 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query"; import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
import { import {
searchAttachments, searchAttachments,
searchPage, searchPage,
@@ -32,6 +32,7 @@ export function useSearchSuggestionsQuery(
staleTime: 60 * 1000, // 1min staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(queryParams), queryFn: () => searchSuggestions(queryParams),
enabled: preload || !!params.query, enabled: preload || !!params.query,
placeholderData: keepPreviousData,
}); });
} }
@@ -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,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) {
@@ -113,7 +113,7 @@ export async function getInvitationById(data: {
export async function createWorkspace( export async function createWorkspace(
data: ISetupWorkspace, data: ISetupWorkspace,
): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> { ): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> {
const req = await api.post("/workspace/create", data); const req = await api.post("/workspace/create", data);
return req.data; return req.data;
} }
@@ -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;
};
+1
View File
@@ -12,6 +12,7 @@ const APP_ROUTE = {
SELECT_WORKSPACE: "/select", SELECT_WORKSPACE: "/select",
MFA_CHALLENGE: "/login/mfa", MFA_CHALLENGE: "/login/mfa",
MFA_SETUP_REQUIRED: "/login/mfa/setup", MFA_SETUP_REQUIRED: "/login/mfa/setup",
VERIFY_EMAIL: "/verify-email",
}, },
SETTINGS: { SETTINGS: {
ACCOUNT: { ACCOUNT: {
+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>
); );
} }
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.70.0", "version": "0.70.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -107,6 +107,7 @@
"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.5.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"tseep": "^1.3.1", "tseep": "^1.3.1",
"typesense": "^2.1.0", "typesense": "^2.1.0",
+2
View File
@@ -25,6 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis'; import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module'; import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
const enterpriseModules = []; const enterpriseModules = [];
try { try {
@@ -47,6 +48,7 @@ try {
middleware: { mount: true }, middleware: { mount: true },
}), }),
LoggerModule, LoggerModule,
NoopAuditModule,
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
-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
@@ -0,0 +1,142 @@
import { containsDomain } from './no-urls.validator';
// containsDomain returns true if value contains a domain-like pattern
// The full NoUrls validator also checks for https:// URLs separately
describe('containsDomain', () => {
describe('bare domains with real TLDs — should block', () => {
it.each([
'example.com',
'example.net',
'example.org',
'example.io',
'example.co',
'example.dev',
'example.app',
'example.me',
'example.info',
'example.tech',
'example.aero',
'example.cloud',
'example.museum',
'example.abc',
'example.uk',
'example.de',
'example.fr',
'example.ru',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domains with paths — should block', () => {
it.each([
'example.com/reset',
'example.com/reset-password',
'click example.com/page',
'go to example.net/login',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('multi-part domains — should block', () => {
it.each([
'Foo.com.net',
'Foo.com.',
'Foo.mine.net',
'Foo.mine.ne',
'sub.example.com',
'login.example.co.uk',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domain in sentence — should block', () => {
it.each([
'Reset your password at example.com',
'URGENT click example.com/reset',
'Visit example.org for details',
'go to mysite.io now',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('case insensitive — should block', () => {
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('fake TLDs — should allow', () => {
it.each([
'Foo.mine',
'Foo.blarg',
'Foo.qqq',
'Foo.zz',
'Foo.abcd',
'Foo.abcde',
'Foo.abcdef',
'Foo.abcdefg',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('too short suffix — should allow', () => {
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('multi-part with fake TLD — should allow', () => {
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('emails — should allow', () => {
it.each([
'user@example.com',
'admin@company.org',
'test@sub.domain.co.uk',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('normal names — should allow', () => {
it.each([
'John Smith',
'Dr. Smith',
'A. B. Charlie',
'John',
'Mary Jane',
"O'Brien",
'Jean-Pierre',
'José García',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('IP addresses — should allow', () => {
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
describe('edge cases — should allow', () => {
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
});
@@ -0,0 +1,42 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import * as tlds from 'tlds';
const URL_PATTERN = /https?:\/\//i;
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
export function containsDomain(value: string): boolean {
const tokens = value.split(/\s+/);
for (const token of tokens) {
if (token.includes('@')) continue;
const segments = token.split('.');
for (let i = 1; i < segments.length; i++) {
const suffix = segments[i].replace(/[^\w].*/g, '');
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
return true;
}
}
}
return false;
}
export function NoUrls(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'noUrls',
target: object.constructor,
propertyName,
options: {
message: 'Must not contain URLs or domain names',
...validationOptions,
},
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return true;
if (URL_PATTERN.test(value)) return false;
if (containsDomain(value)) return false;
return true;
},
},
});
};
}
@@ -1,3 +1,4 @@
export enum UserTokenType { export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password', FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
} }
+32
View File
@@ -1,5 +1,37 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Workspace } from '@docmost/db/types/entity.types'; import { Workspace } from '@docmost/db/types/entity.types';
import { createHmac } from 'node:crypto';
export function computeEmailSignature(
email: string,
workspaceId: string,
appSecret: string,
): string {
return createHmac('sha256', appSecret)
.update(`${email.toLowerCase()}:${workspaceId}`)
.digest('hex');
}
export function throwIfEmailNotVerified(opts: {
isCloud: boolean;
emailVerifiedAt: Date | null;
email: string;
workspaceId: string;
appSecret: string;
}): void {
if (!opts.isCloud || opts.emailVerifiedAt) return;
const emailSignature = computeEmailSignature(
opts.email,
opts.workspaceId,
opts.appSecret,
);
throw new BadRequestException({
message:
'Please verify your email address. Check your inbox for the verification link.',
emailSignature,
});
}
export function validateSsoEnforcement(workspace: Workspace) { export function validateSsoEnforcement(workspace: Workspace) {
if (workspace.enforceSso) { if (workspace.enforceSso) {
@@ -7,11 +7,13 @@ import {
} from 'class-validator'; } from 'class-validator';
import { CreateUserDto } from './create-user.dto'; import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto { export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty() @IsNotEmpty()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(50)
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@@ -7,12 +7,14 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto { export class CreateUserDto {
@IsOptional() @IsOptional()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(50)
@IsString() @IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@@ -17,6 +17,7 @@ import {
isUserDisabled, isUserDisabled,
nanoIdGen, nanoIdGen,
} from '../../../common/helpers'; } from '../../../common/helpers';
import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto'; import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service'; import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -36,6 +37,7 @@ import {
AUDIT_SERVICE, AUDIT_SERVICE,
IAuditService, IAuditService,
} from '../../../integrations/audit/audit.service'; } from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -46,6 +48,7 @@ export class AuthService {
private userTokenRepo: UserTokenRepo, private userTokenRepo: UserTokenRepo,
private mailService: MailService, private mailService: MailService,
private domainService: DomainService, private domainService: DomainService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -69,6 +72,14 @@ export class AuthService {
throw new UnauthorizedException(errorMessage); throw new UnauthorizedException(errorMessage);
} }
throwIfEmailNotVerified({
isCloud: this.environmentService.isCloud(),
emailVerifiedAt: user.emailVerifiedAt,
email: user.email,
workspaceId,
appSecret: this.environmentService.getAppSecret(),
});
user.lastLoginAt = new Date(); user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId); await this.userRepo.updateLastLogin(user.id, workspaceId);
@@ -247,6 +258,14 @@ export class AuthService {
template: emailTemplate, template: emailTemplate,
}); });
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
await this.userRepo.updateUser(
{ emailVerifiedAt: new Date() },
user.id,
workspace.id,
);
}
// Check if user has MFA enabled or workspace enforces MFA // Check if user has MFA enabled or workspace enforces MFA
const userHasMfa = user?.['mfa']?.isEnabled || false; const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false; const workspaceEnforcesMfa = workspace.enforceMfa || false;
-11
View File
@@ -20,10 +20,6 @@ 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 {
AUDIT_SERVICE,
NoopAuditService,
} from '../integrations/audit/audit.service';
import { ClsMiddleware } from 'nestjs-cls'; import { ClsMiddleware } from 'nestjs-cls';
@Module({ @Module({
@@ -43,13 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
NotificationModule, NotificationModule,
WatcherModule, WatcherModule,
], ],
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
}) })
export class CoreModule implements NestModule { export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
+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, ),
}),
}; };
} }
} }
@@ -139,10 +139,10 @@ export class SpaceService {
}); });
if ( if (
!this.licenseCheckService.isValidEELicense(workspace.licenseKey) !this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan)
) { ) {
throw new ForbiddenException( throw new ForbiddenException(
'This feature requires a valid enterprise license', 'This feature requires a valid license',
); );
} }
} }
@@ -37,7 +37,6 @@ export class UserController {
const workspaceInfo = { const workspaceInfo = {
...rest, ...rest,
memberCount, memberCount,
hasLicenseKey: Boolean(licenseKey),
}; };
return { user: authUser, workspace: workspaceInfo }; return { user: authUser, workspace: workspaceInfo };
@@ -32,8 +32,10 @@ import {
} from '../../casl/interfaces/workspace-ability.type'; } from '../../casl/interfaces/workspace-ability.type';
import { FastifyReply } from 'fastify'; import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto'; import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto'; import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('workspace') @Controller('workspace')
@@ -42,7 +44,9 @@ export class WorkspaceController {
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly workspaceAbility: WorkspaceAbilityFactory, private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private licenseCheckService: LicenseCheckService,
) {} ) {}
@Public() @Public()
@@ -58,6 +62,23 @@ export class WorkspaceController {
return this.workspaceService.getWorkspaceInfo(workspace.id); return this.workspaceService.getWorkspaceInfo(workspace.id);
} }
@HttpCode(HttpStatus.OK)
@Post('entitlements')
async getEntitlements(@AuthWorkspace() workspace: Workspace) {
let { licenseKey } = workspace;
const { plan } = workspace;
if (!licenseKey) {
licenseKey = await this.workspaceRepo.findLicenseKeyById(workspace.id);
}
return {
cloud: this.environmentService.isCloud(),
tier: this.licenseCheckService.resolveTier(licenseKey, plan),
features: this.licenseCheckService.resolveFeatures(licenseKey, plan),
};
}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
async updateWorkspace( async updateWorkspace(
@@ -12,6 +12,7 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission'; import { UserRole } from '../../../common/helpers/types/permission';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto { export class InviteUserDto {
@IsArray() @IsArray()
@@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2) @MinLength(2)
@MaxLength(60) @MaxLength(60)
@IsString() @IsString()
@NoUrls()
name: string; name: string;
@MinLength(8) @MinLength(8)
@@ -85,7 +85,7 @@ export class WorkspaceService {
async getWorkspacePublicData(workspaceId: string) { async getWorkspacePublicData(workspaceId: string) {
const workspace = await this.db const workspace = await this.db
.selectFrom('workspaces') .selectFrom('workspaces')
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey']) .select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey', 'plan'])
.select((eb) => .select((eb) =>
jsonArrayFrom( jsonArrayFrom(
eb eb
@@ -106,12 +106,9 @@ export class WorkspaceService {
throw new NotFoundException('Workspace not found'); throw new NotFoundException('Workspace not found');
} }
const { licenseKey, ...rest } = workspace; const { licenseKey, plan, ...rest } = workspace;
return { return rest;
...rest,
hasLicenseKey: Boolean(licenseKey),
};
} }
async create( async create(
@@ -244,7 +241,7 @@ export class WorkspaceService {
await this.billingQueue.add( await this.billingQueue.add(
QueueJob.WELCOME_EMAIL, QueueJob.WELCOME_EMAIL,
{ userId: user.id }, { userId: user.id },
{ delay: 60 * 1000 }, // 1m { delay: 30 * 60 * 1000 }, // 30m
); );
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
@@ -332,14 +329,32 @@ export class WorkspaceService {
) { ) {
const ws = await this.db const ws = await this.db
.selectFrom('workspaces') .selectFrom('workspaces')
.select(['id', 'licenseKey', 'trashRetentionDays']) .select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
.where('id', '=', workspaceId) .where('id', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) { if (!ws) {
throw new ForbiddenException( throw new NotFoundException('Workspace not found');
'This feature requires a valid enterprise license', }
);
if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'mcp', ws.plan)) {
throw new ForbiddenException(
'This feature requires a valid license',
);
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
) {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) {
throw new ForbiddenException(
'This feature requires a valid license',
);
}
} }
if ( if (
@@ -503,10 +518,7 @@ export class WorkspaceService {
} }
const { licenseKey, ...rest } = workspace; const { licenseKey, ...rest } = workspace;
return { return rest;
...rest,
hasLicenseKey: Boolean(licenseKey),
};
} }
async getWorkspaceUsers( async getWorkspaceUsers(
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}

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