Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho ac47eb8dbe fix page level comment on mobile
* add icon next to comment box
2026-03-14 00:52:40 +00:00
101 changed files with 692 additions and 2237 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.70.3",
"version": "0.70.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -289,11 +289,6 @@
"Save & Exit": "Save & Exit",
"Double-click to edit Excalidraw diagram": "Double-click to edit Excalidraw diagram",
"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",
"Remove link": "Remove link",
"Add link": "Add link",
@@ -444,6 +439,7 @@
"Toggle space public sharing": "Toggle space public sharing",
"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.",
"Requires an enterprise license": "Requires an enterprise license",
"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.",
"Enable public sharing": "Enable public sharing",
@@ -625,9 +621,7 @@
"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.",
"Toggle generative AI": "Toggle generative AI",
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
"Enterprise feature": "Enterprise feature",
"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": "AI",
@@ -635,15 +629,17 @@
"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.",
"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",
"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",
"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:",
"Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
"View the <anchor>API documentation</anchor> for usage details.": "View the <anchor>API documentation</anchor> for usage details.",
"View the <anchor>MCP documentation</anchor>.": "View the <anchor>MCP documentation</anchor>.",
"View the": "View the",
"for usage details.": "for usage details.",
"for setup instructions.": "for setup instructions.",
"API documentation": "API documentation",
"Sources": "Sources",
"AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available",
@@ -658,12 +654,12 @@
"Mark all as read": "Mark all as read",
"Mark as read": "Mark as read",
"More options": "More options",
"<bold>{{name}}</bold> mentioned you in a comment": "<bold>{{name}}</bold> mentioned you in a comment",
"<bold>{{name}}</bold> commented on a page": "<bold>{{name}}</bold> commented on a page",
"<bold>{{name}}</bold> resolved a comment": "<bold>{{name}}</bold> resolved a comment",
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page",
"mentioned you in a comment": "mentioned you in a comment",
"commented on a page": "commented on a page",
"resolved a comment": "resolved a comment",
"mentioned you on a page": "mentioned you on a page",
"gave you edit access to a page": "gave you edit access to a page",
"gave you view access to a page": "gave you view access to a page",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
@@ -697,16 +693,5 @@
"Failed to update trash retention": "Failed to update trash retention",
"Removed page restriction": "Removed page restriction",
"Added page permission": "Added 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."
"Removed page permission": "Removed page permission"
}
-2
View File
@@ -38,7 +38,6 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
export default function App() {
const { t } = useTranslation();
@@ -64,7 +63,6 @@ export default function App() {
<>
<Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} />
<Route path={"/verify-email"} element={<VerifyEmail />} />
</>
)}
@@ -21,9 +21,7 @@ import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
prefetchApiKeyManagement,
prefetchApiKeys,
@@ -41,19 +39,22 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
type DataItem = {
interface DataItem {
label: string;
icon: React.ElementType;
path: string;
feature?: string;
role?: "admin" | "owner";
env?: "cloud" | "selfhosted";
};
isCloud?: boolean;
isEnterprise?: boolean;
isAdmin?: boolean;
isOwner?: boolean;
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
type DataGroup = {
interface DataGroup {
heading: string;
items: DataItem[];
};
}
const groupedData: DataGroup[] = [
{
@@ -69,7 +70,9 @@ const groupedData: DataGroup[] = [
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
feature: Feature.API_KEYS,
isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
},
],
},
@@ -77,20 +80,26 @@ const groupedData: DataGroup[] = [
heading: "Workspace",
items: [
{ label: "General", icon: IconSettings, path: "/settings/workspace" },
{ label: "Members", icon: IconUsers, path: "/settings/members" },
{
label: "Members",
icon: IconUsers,
path: "/settings/members",
},
{
label: "Billing",
icon: IconCoin,
path: "/settings/billing",
role: "admin",
env: "cloud",
isCloud: true,
isAdmin: true,
},
{
label: "Security & SSO",
icon: IconLock,
path: "/settings/security",
feature: Feature.SECURITY_SETTINGS,
role: "admin",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
@@ -99,22 +108,25 @@ const groupedData: DataGroup[] = [
label: "API management",
icon: IconKey,
path: "/settings/api-keys",
feature: Feature.API_KEYS,
role: "admin",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
role: "admin",
isAdmin: true,
},
{
label: "Audit log",
icon: IconHistory,
path: "/settings/audit",
feature: Feature.AUDIT_LOGS,
role: "owner",
env: "selfhosted",
isEnterprise: true,
isOwner: true,
isSelfhosted: true,
showDisabledInNonEE: true,
},
],
},
@@ -136,8 +148,7 @@ export default function SettingsSidebar() {
const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation();
const { isAdmin, isOwner } = useUserRole();
const [entitlements] = useAtom(entitlementAtom);
const upgradeLabel = useUpgradeLabel();
const [workspace] = useAtom(workspaceAtom);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -145,20 +156,43 @@ export default function SettingsSidebar() {
setActive(location.pathname);
}, [location.pathname]);
const hasFeature = (f: string) =>
entitlements?.features?.includes(f) ?? false;
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;
const hasRoleAccess = (item: DataItem) => {
if (item.isOwner) return isOwner;
if (item.isAdmin) return isAdmin;
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) => {
if (!item.feature) return false;
return !hasFeature(item.feature);
if (item.showDisabledInNonEE && item.isEnterprise) {
return !(isCloud() || workspace?.hasLicenseKey);
}
return false;
};
const menuItems = groupedData.map((group) => {
@@ -191,7 +225,7 @@ export default function SettingsSidebar() {
prefetchHandler = prefetchBilling;
break;
case "License & Edition":
if (entitlements?.tier !== "free") {
if (workspace?.hasLicenseKey) {
prefetchHandler = prefetchLicense;
}
break;
@@ -246,7 +280,7 @@ export default function SettingsSidebar() {
return (
<Tooltip
key={item.label}
label={upgradeLabel}
label={t("Available in enterprise edition")}
position="right"
withArrow
>
@@ -34,7 +34,6 @@ export function AutoTooltipText({
disabled={!isTruncated || !label}
multiline
withArrow
withinPortal={false}
{...tooltipProps}
>
<Text
@@ -1,13 +1,12 @@
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
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 useLicense from "@/ee/hooks/use-license.tsx";
export default function EnableAiSearch() {
const { t } = useTranslation();
@@ -38,8 +37,9 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -56,16 +56,14 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
</Tooltip>
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
);
}
@@ -1,20 +1,17 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { Group, Text, Switch } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function EnableGenerativeAi() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const hasAccess = useIsCloudEE();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -41,13 +38,11 @@ export default function EnableGenerativeAi() {
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Tooltip>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
);
}
@@ -13,12 +13,10 @@ import {
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
@@ -27,8 +25,7 @@ export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const hasAccess = useIsCloudEE();
const mcpUrl = `${getAppUrl()}/mcp`;
@@ -49,7 +46,11 @@ export default function McpSettings() {
return (
<Stack gap="lg">
{!hasAccess && (
<Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
>
{t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
@@ -63,22 +64,23 @@ export default function McpSettings() {
{t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
)}{" "}
<Trans
i18nKey="View the <anchor>MCP documentation</anchor>."
components={{
anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />,
}}
/>
{t("View the")}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
target="_blank"
size="sm"
>
{t("MCP documentation")}
</Anchor>
.
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Tooltip>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
{checked && (
@@ -87,7 +89,11 @@ export default function McpSettings() {
{t("MCP Server URL")}
</Text>
<Group gap="xs">
<TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
<TextInput
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
@@ -117,36 +123,12 @@ export default function McpSettings() {
{t("Supported tools")}
</Text>
<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>
<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.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</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>
</div>
</div>
+3 -6
View File
@@ -9,17 +9,14 @@ import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const hasAccess = useIsCloudEE();
const location = useLocation();
const navigate = useNavigate();
@@ -58,7 +55,7 @@ export default function AiSettings() {
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={upgradeLabel}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
@@ -5,14 +5,12 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function RestrictApiToAdmins() {
const { t } = useTranslation();
@@ -20,8 +18,7 @@ export default function RestrictApiToAdmins() {
const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true,
);
const hasAccess = useHasFeature(Feature.API_KEYS);
const upgradeLabel = useUpgradeLabel();
const hasAccess = useEnterpriseAccess();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -54,7 +51,7 @@ export default function RestrictApiToAdmins() {
<ResponsiveSettingsControl>
<Tooltip
label={upgradeLabel}
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -58,12 +58,11 @@ export default function UserApiKeys() {
<SettingsTitle title={t("API keys")} />
<Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
{t("View the")}{" "}
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
{t("API documentation")}
</Anchor>{" "}
{t("for usage details.")}
</Text>
{mcpEnabled && canCreate && (
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -56,12 +56,12 @@ export default function WorkspaceApiKeys() {
<SettingsTitle title={t("API management")} />
<Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
{t("Manage API keys for all users in the workspace.")}{" "}
{t("View the")}{" "}
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
{t("API documentation")}
</Anchor>{" "}
{t("for usage details.")}
</Text>
<RestrictApiToAdmins />
@@ -5,15 +5,3 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data;
}
export async function findWorkspacesByEmail(email: string): Promise<void> {
await api.post("/workspace/find-by-email", { email });
}
export async function verifyEmail(data: { token: string }): Promise<void> {
await api.post("/workspace/verify-email", data);
}
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
await api.post("/workspace/resend-verification", data);
}
@@ -20,21 +20,14 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
const formSchema = z.object({
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() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
@@ -44,13 +37,6 @@ export function CloudLoginForm() {
},
});
const findForm = useForm<any>({
validate: zod4Resolver(findWorkspaceSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: { hostname: string }) {
setIsLoading(true);
@@ -68,19 +54,6 @@ export function CloudLoginForm() {
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 (
<div>
<Container size={420} className={classes.container}>
@@ -110,38 +83,6 @@ export function CloudLoginForm() {
{t("Continue")}
</Button>
</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>
</Container>
+2 -1
View File
@@ -6,6 +6,7 @@ import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() {
@@ -56,7 +57,7 @@ export default function SsoLogin() {
/>
)}
{data.authProviders.length > 0 && (
{(isCloud() || data.hasLicenseKey) && (
<>
<Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => (
@@ -1,7 +0,0 @@
import { atomWithStorage } from "jotai/utils";
import type { Entitlements } from "./entitlement.types";
export const entitlementAtom = atomWithStorage<Entitlements | null>(
"entitlements",
null,
);
@@ -1,7 +0,0 @@
import api from "@/lib/api-client";
import { Entitlements } from "./entitlement.types";
export async function getEntitlements(): Promise<Entitlements> {
const req = await api.post<Entitlements>("/workspace/entitlements");
return req.data as Entitlements;
}
@@ -1,7 +0,0 @@
export type Tier = "free" | "standard" | "business" | "enterprise";
export type Entitlements = {
cloud: boolean;
tier: Tier;
features: string[];
};
@@ -1,11 +0,0 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getEntitlements } from "./entitlement-service";
import { Entitlements } from "./entitlement.types";
export function useEntitlements(): UseQueryResult<Entitlements> {
return useQuery({
queryKey: ["entitlements"],
queryFn: getEntitlements,
staleTime: 5 * 60 * 1000,
});
}
-19
View File
@@ -1,19 +0,0 @@
export const Feature = {
SSO_CUSTOM: 'sso:custom',
SSO_GOOGLE: 'sso:google',
MFA: 'mfa',
API_KEYS: 'api:keys',
COMMENT_RESOLUTION: 'comment:resolution',
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
} as const;
@@ -0,0 +1,12 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
-7
View File
@@ -1,7 +0,0 @@
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export const useHasFeature = (feature: string): boolean => {
const [entitlements] = useAtom(entitlementAtom);
return entitlements?.features?.includes(feature) ?? false;
};
+9
View File
@@ -0,0 +1,9 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export const useLicense = () => {
const [currentUser] = useAtom(currentUserAtom);
return { hasLicenseKey: currentUser?.workspace?.hasLicenseKey };
};
export default useLicense;
@@ -1,16 +0,0 @@
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { isCloud } from "@/lib/config";
export function useUpgradeLabel(): string {
const { t } = useTranslation();
const [entitlements] = useAtom(entitlementAtom);
if (!isCloud()) {
return entitlements != null && entitlements.tier !== "free"
? t("Upgrade your license tier.")
: t("Available with a paid license");
}
return t("Upgrade your plan");
}
@@ -7,22 +7,21 @@ import { useTranslation } from "react-i18next";
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
import { useDisclosure } from "@mantine/hooks";
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import RemoveLicense from "@/ee/licence/components/remove-license.tsx";
export default function ActivateLicense() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [entitlements] = useAtom(entitlementAtom);
const hasLicense = entitlements != null && entitlements.tier !== "free";
const [workspace] = useAtom(workspaceAtom);
return (
<Group justify="flex-end" wrap="nowrap" mb="sm">
<Button onClick={open}>
{hasLicense ? t("Update license") : t("Add license")}
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
</Button>
{hasLicense && <RemoveLicense />}
{workspace?.hasLicenseKey && <RemoveLicense />}
<Modal
size="550"
@@ -60,7 +59,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset();
onClose?.();
onClose();
}
return (
@@ -31,8 +31,7 @@ export default function LicenseDetails() {
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
{license.licenseType === "business" ? "Business" : "Enterprise"}{" "}
{license.trial && <Badge color="green">Trial</Badge>}
Enterprise {license.trial && <Badge color="green">Trial</Badge>}
</Table.Td>
</Table.Tr>
+3 -4
View File
@@ -8,11 +8,10 @@ import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.
import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function License() {
const [entitlements] = useAtom(entitlementAtom);
const hasLicense = entitlements != null && entitlements.tier !== "free";
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
if (!isAdmin) {
@@ -30,7 +29,7 @@ export default function License() {
<InstallationDetails />
{hasLicense ? <LicenseDetails /> : <OssDetails />}
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
</>
);
}
@@ -31,7 +31,6 @@ export function useActivateMutation() {
queryKey: ["license"],
});
queryClient.refetchQueries({ queryKey: ["currentUser"] });
queryClient.refetchQueries({ queryKey: ["entitlements"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
@@ -48,7 +47,6 @@ export function useRemoveLicenseMutation() {
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["license"] });
queryClient.refetchQueries({ queryKey: ["currentUser"] });
queryClient.refetchQueries({ queryKey: ["entitlements"] });
},
});
}
@@ -1,10 +1,7 @@
export type LicenseType = 'business' | 'enterprise';
export interface ILicenseInfo {
id: string;
customerName: string;
seatCount: number;
licenseType: LicenseType;
issuedAt: Date;
expiresAt: Date;
trial: boolean;
@@ -7,9 +7,8 @@ import { getMfaStatus } from "@/ee/mfa";
import { MfaSetupModal } from "@/ee/mfa";
import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa";
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 useLicense from "@/ee/hooks/use-license.tsx";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
export function MfaSettings() {
@@ -18,8 +17,7 @@ export function MfaSettings() {
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
const canUseMfa = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const { hasLicenseKey } = useLicense();
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"],
@@ -30,6 +28,8 @@ export function MfaSettings() {
return null;
}
const canUseMfa = isCloud() || hasLicenseKey;
// Check if MFA is truly enabled
const isMfaEnabled = mfaStatus?.isEnabled === true;
@@ -69,7 +69,7 @@ export function MfaSettings() {
<ResponsiveSettingsControl>
{!isMfaEnabled ? (
<Tooltip
label={upgradeLabel}
label={t("Available in enterprise edition")}
disabled={canUseMfa}
>
<Button
@@ -19,8 +19,7 @@ import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-p
import { PagePermissionTab } from "@/ee/page-permission";
import { PublishTab } from "./publish-tab";
import { useShareForPageQuery } from "@/features/share/queries/share-query";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { useSpaceQuery } from "@/features/space/queries/space-query";
@@ -34,9 +33,9 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const { pageSlug, spaceSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false);
const hasPagePermissions = useHasFeature(Feature.PAGE_PERMISSIONS);
const isCloudEE = useIsCloudEE();
const [activeTab, setActiveTab] = useState<string | null>(
hasPagePermissions ? "access" : "publish",
isCloudEE ? "access" : "publish",
);
const [workspace] = useAtom(workspaceAtom);
@@ -52,7 +51,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && hasPagePermissions ? pageId : undefined);
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
return (
<>
@@ -93,7 +92,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
</Tabs.List>
<Tabs.Panel value="access">
{!hasPagePermissions ? (
{!isCloudEE ? (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
-107
View File
@@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Container, Title, Text, Button, Box } from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import {
verifyEmail,
resendVerificationEmail,
} from "@/ee/cloud/service/cloud-service.ts";
import { notifications } from "@mantine/notifications";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
export default function VerifyEmail() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const rawEmail = searchParams.get("email");
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
const sig = searchParams.get("sig");
const [isResending, setIsResending] = useState(false);
const [resent, setResent] = useState(false);
useEffect(() => {
if (token) {
handleVerify(token);
}
}, [token]);
async function handleVerify(verifyToken: string) {
try {
await verifyEmail({ token: verifyToken });
navigate(APP_ROUTE.HOME);
} catch (err) {
notifications.show({
message: t("Verification failed. The link may have expired."),
color: "red",
});
navigate(APP_ROUTE.AUTH.LOGIN);
}
}
async function handleResend() {
if (!email || !sig) return;
setIsResending(true);
try {
await resendVerificationEmail({ email, sig });
setResent(true);
} catch {
notifications.show({
message: t("Failed to resend verification email. Please try again."),
color: "red",
});
}
setIsResending(false);
}
if (token) {
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Verifying your email")}
</Title>
<Text ta="center" c="dimmed">
{t("Please wait...")}
</Text>
</Box>
</Container>
);
}
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Check your email")}
</Title>
<Text ta="center" c="dimmed" mb="md">
{email
? t("We sent a verification link to {{email}}.", { email })
: t("We sent a verification link to your email.")}
</Text>
<Text ta="center" size="sm" c="dimmed" mb="lg">
{t("Click the link to verify your email and access your workspace.")}
</Text>
{email && sig && !resent && (
<Button
fullWidth
variant="light"
onClick={handleResend}
loading={isResending}
>
{t("Resend verification email")}
</Button>
)}
{resent && (
<Text ta="center" size="sm" c="dimmed">
{t("Verification email sent. Please check your inbox.")}
</Text>
)}
</Box>
</Container>
);
}
@@ -6,23 +6,21 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
export default function DisablePublicSharing() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<DisablePublicSharingToggle />
<DisablePublicSharingToggle />
</Group>
);
}
@@ -33,8 +31,7 @@ function DisablePublicSharingToggle() {
const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true,
);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const hasAccess = useEnterpriseAccess();
const applyChange = async (value: boolean) => {
try {
@@ -75,11 +72,15 @@ function DisablePublicSharingToggle() {
};
return (
<Tooltip label={upgradeLabel} disabled={hasSharingControls} refProp="rootRef">
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasSharingControls}
disabled={!hasAccess}
aria-label={t("Toggle public sharing")}
/>
</Tooltip>
@@ -1,20 +1,10 @@
import {
Group,
Text,
Switch,
MantineSize,
Title,
Tooltip,
} from "@mantine/core";
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
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() {
const { t } = useTranslation();
@@ -43,8 +33,6 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceMfa);
const hasAccess = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -61,16 +49,13 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle MFA enforcement")}
/>
</Tooltip>
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle MFA enforcement")}
/>
);
}
@@ -1,13 +1,10 @@
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { Group, Text, Switch, MantineSize } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
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() {
const { t } = useTranslation();
@@ -36,8 +33,6 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceSso);
const hasAccess = useHasFeature(Feature.SSO_CUSTOM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -54,16 +49,13 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle sso enforcement")}
/>
</Tooltip>
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle sso enforcement")}
/>
);
}
@@ -6,9 +6,6 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpacePublicSharingToggleProps = {
space: ISpace;
@@ -20,9 +17,6 @@ export default function SpacePublicSharingToggle({
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasSharingControls || workspaceDisabled;
const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true,
);
@@ -74,14 +68,14 @@ export default function SpacePublicSharingToggle({
</Text>
</div>
<Tooltip
label={!hasSharingControls ? upgradeLabel : t("Public sharing is disabled at the workspace level")}
disabled={!isDisabled}
label={t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
disabled={workspaceDisabled}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
@@ -12,18 +12,13 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
type RetentionUnit = "days" | "months" | "years";
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) {
return { amount: days / 365, unit: "years" };
}
@@ -41,19 +36,14 @@ function retentionToDays(amount: number, unit: RetentionUnit): number {
export default function TrashRetention() {
const { t } = useTranslation();
const hasRetention = useHasFeature(Feature.RETENTION);
const upgradeLabel = useUpgradeLabel();
const hasAccess = useEnterpriseAccess();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(
parsed.amount,
);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(
parsed.unit,
);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -73,17 +63,14 @@ export default function TrashRetention() {
setSaving(true);
try {
const updatedWorkspace = await updateWorkspace({
trashRetentionDays: days,
});
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
setWorkspace(updatedWorkspace);
notifications.show({
message: t("Trash retention updated"),
});
} catch (err: any) {
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",
});
const { amount, unit } = daysToRetention(currentDays);
@@ -94,11 +81,10 @@ export default function TrashRetention() {
}
};
const isDirty =
retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit,
) !== currentDays;
const isDirty = retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit,
) !== currentDays;
return (
<div>
@@ -107,7 +93,10 @@ export default function TrashRetention() {
{t("Pages in trash will be permanently deleted after this period.")}
</Text>
<Tooltip label={upgradeLabel} disabled={hasRetention}>
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
>
<Group gap="xs" wrap="nowrap" maw={320}>
<NumberInput
value={retentionAmount}
@@ -116,7 +105,7 @@ export default function TrashRetention() {
hideControls
size="sm"
w={60}
disabled={!hasRetention}
disabled={!hasAccess}
/>
<Select
data={[
@@ -132,13 +121,13 @@ export default function TrashRetention() {
}}
size="sm"
style={{ flex: 1 }}
disabled={!hasRetention}
disabled={!hasAccess}
/>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!hasRetention || !isDirty}
disabled={!hasAccess || !isDirty}
>
{t("Save")}
</Button>
+24 -13
View File
@@ -12,15 +12,14 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasRetention = useHasFeature(Feature.RETENTION);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const hasEnterpriseAccess = useEnterpriseAccess();
const isCloudEE = useIsCloudEE();
if (!isAdmin) {
return null;
@@ -37,27 +36,39 @@ export default function Security() {
<Divider my="lg" />
<DisablePublicSharing />
<Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && (
<>
<DisablePublicSharing />
<Divider my="lg" />
</>
)}
<TrashRetention />
<Divider my="lg" />
{!isCloud() && (
<>
<TrashRetention />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
<EnforceSso />
<Divider my="lg" />
{hasEnterpriseAccess && (
<>
<EnforceSso />
<Divider my="lg" />
</>
)}
{(isCloud() || hasCustomSso) && (
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasCustomSso && (
{hasEnterpriseAccess && (
<>
<CreateSsoProvider />
<Divider size={0} my="lg" />
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1, { message: "Name is required" }).max(50),
name: z.string().min(1).max(50),
email: z
.email({ message: "Invalid email address" })
.min(1, { message: "Email is required" }),
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
.email()
.min(1, { message: "email is required" }),
password: z.string().min(8),
});
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 { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@@ -52,18 +52,9 @@ export default function useAuth() {
}
} catch (err) {
setIsLoading(false);
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;
}
console.log(err);
notifications.show({
message,
message: err.response?.data.message,
color: "red",
});
}
@@ -101,17 +92,6 @@ export default function useAuth() {
try {
if (isCloud()) {
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 exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) {
@@ -50,5 +50,4 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data;
}
}
@@ -7,8 +7,7 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import ResolveComment from "@/ee/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
@@ -45,7 +44,7 @@ function CommentListItem({
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom);
const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const isCloudEE = useIsCloudEE();
const createdAtAgo = useTimeAgo(comment.createdAt);
useEffect(() => {
@@ -82,7 +81,7 @@ function CommentListItem({
}
async function handleResolveComment() {
if (!canResolve) return;
if (!isCloudEE) return;
try {
const isResolved = comment.resolvedAt != null;
@@ -138,7 +137,7 @@ function CommentListItem({
</Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && canResolve && (
{!comment.parentCommentId && canComment && isCloudEE && (
<ResolveComment
editor={editor}
commentId={comment.id}
@@ -1,16 +1,8 @@
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 { useTranslation } from "react-i18next";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
type CommentMenuProps = {
onEditComment: () => void;
@@ -21,17 +13,16 @@ type CommentMenuProps = {
isParentComment?: boolean;
};
function CommentMenu({
onEditComment,
onDeleteComment,
function CommentMenu({
onEditComment,
onDeleteComment,
onResolveComment,
canEdit = true,
isResolved = false,
isParentComment = false,
isParentComment = false
}: CommentMenuProps) {
const { t } = useTranslation();
const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const upgradeLabel = useUpgradeLabel();
const isCloudEE = useIsCloudEE();
//@ts-ignore
const openDeleteModal = () =>
@@ -53,34 +44,33 @@ function CommentMenu({
<Menu.Dropdown>
{canEdit && (
<Menu.Item
onClick={onEditComment}
leftSection={<IconEdit size={14} />}
>
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}>
{t("Edit comment")}
</Menu.Item>
)}
{isParentComment &&
(canResolve ? (
<Menu.Item
onClick={onResolveComment}
{isParentComment && (
isCloudEE ? (
<Menu.Item
onClick={onResolveComment}
leftSection={
isResolved ? (
<IconCircleCheckFilled size={14} />
) : (
isResolved ?
<IconCircleCheckFilled size={14} /> :
<IconCircleCheck size={14} />
)
}
>
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
) : (
<Tooltip label={upgradeLabel} position="left">
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
<Tooltip label={t("Available in enterprise edition")} position="left">
<Menu.Item
disabled
leftSection={<IconCircleCheck size={14} />}
>
{t("Resolve comment")}
</Menu.Item>
</Tooltip>
))}
)
)}
<Menu.Item
leftSection={<IconTrash size={14} />}
onClick={openDeleteModal}
@@ -10,5 +10,3 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
@@ -26,7 +26,7 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
@@ -49,8 +49,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
@@ -60,10 +58,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
@@ -141,7 +135,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showLinkMenuRef.current ||
showCommentPopupRef?.current
) {
return false;
@@ -154,6 +147,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => {
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
},
},
@@ -161,10 +155,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu || showLinkMenu) return;
if (showAiMenu) return;
return (
<BubbleMenu
@@ -194,6 +189,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
@@ -204,6 +200,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
@@ -227,7 +224,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
))}
</ActionIcon.Group>
<LinkSelector />
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={(value) => {
setIsLinkSelectorOpen(value);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<ColorSelector
editor={props.editor}
@@ -236,6 +242,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
@@ -1,25 +1,66 @@
import { FC } from "react";
import { Dispatch, FC, SetStateAction, useCallback } from "react";
import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { useSetAtom } from "jotai";
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { useTranslation } from "react-i18next";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
export const LinkSelector: FC = () => {
interface LinkSelectorProps {
editor: ReturnType<typeof useEditor>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const { t } = useTranslation();
const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
const onLink = useCallback(
(url: string) => {
setIsOpen(false);
editor
.chain()
.focus()
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
},
[editor, setIsOpen],
);
return (
<Tooltip label={t("Add link")} withArrow>
<ActionIcon
variant="default"
size="lg"
radius="0"
style={{ border: "none" }}
onClick={() => setShowLinkMenu(true)}
>
<IconLink size={16} />
</ActionIcon>
</Tooltip>
<Popover
width={300}
opened={isOpen}
trapFocus
offset={{ mainAxis: 35, crossAxis: 0 }}
withArrow
>
<Popover.Target>
<Tooltip label={t("Add link")} withArrow>
<ActionIcon
variant="default"
size="lg"
radius="0"
style={{
border: "none",
}}
onClick={() => setIsOpen(!isOpen)}
>
<IconLink size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<LinkEditorPanel onSetLink={onLink} />
</Popover.Dropdown>
</Popover>
);
};
@@ -1,200 +1,36 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Group,
ScrollArea,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import { IconFileDescription, IconLink, IconWorld } from "@tabler/icons-react";
import React from "react";
import { Button, Group, TextInput } from "@mantine/core";
import { IconLink } from "@tabler/icons-react";
import { useLinkEditorState } from "@/features/editor/components/link/use-link-editor-state.tsx";
import { LinkEditorPanelProps } from "@/features/editor/components/link/types.ts";
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 = ({
onSetLink,
initialUrl,
onUnsetLink,
}: LinkEditorPanelProps) => {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const { data: space } = useSpaceQuery(spaceSlug);
const state = useLinkEditorState({ onSetLink, initialUrl });
const [selectedIndex, setSelectedIndex] = useState(0);
const viewportRef = useRef<HTMLDivElement>(null);
const { data: suggestion } = useSearchSuggestionsQuery({
query: state.isSearchQuery ? state.url : "",
includeUsers: false,
includePages: true,
spaceId: space?.id,
limit: state.isSearchQuery ? 10 : 3,
preload: true,
const state = useLinkEditorState({
onSetLink,
initialUrl,
});
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 (
<div>
<form onSubmit={state.handleSubmit}>
<TextInput
leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
classNames={{ input: classes.linkInput }}
placeholder={t("Paste link or search pages")}
value={state.url}
onChange={state.onChange}
onKeyDown={handleKeyDown}
data-autofocus
autoFocus
/>
<Group gap="xs" style={{ flex: 1 }} wrap="nowrap">
<TextInput
leftSection={<IconLink size={16} />}
variant="filled"
placeholder={t("Paste link")}
value={state.url}
onChange={state.onChange}
/>
<Button p={"xs"} type="submit" disabled={!state.isValidUrl}>
{t("Save")}
</Button>
</Group>
</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>
);
};
@@ -1,114 +1,103 @@
import { FC, useCallback, useEffect, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import type { Editor } from "@tiptap/react";
import { useAtom } from "jotai";
import { isTextSelected } from "@docmost/editor-ext";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel";
import { normalizeUrl } from "@/features/editor/components/link/link-view";
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import React, { useCallback, useState } from "react";
import { TextSelection } from "@tiptap/pm/state";
import { Paper } from "@mantine/core";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { LinkPreviewPanel } from "@/features/editor/components/link/link-preview.tsx";
import { Card } from "@mantine/core";
import { useEditorState } from "@tiptap/react";
type EditorLinkMenuProps = {
editor: Editor;
};
export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
const [showEdit, setShowEdit] = useState(false);
export const EditorLinkMenu: FC<EditorLinkMenuProps> = ({ editor }) => {
const [showLinkMenu, setShowLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
const shouldShow = useCallback(() => {
return editor.isActive("link");
}, [editor]);
const containerRef = useRef<HTMLDivElement>(null);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
if (showLinkMenu) {
editor.commands.focus();
}
}, [showLinkMenu, editor]);
const focusInput = useCallback(() => {
requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLInputElement>("input")
?.focus({ preventScroll: true });
});
const handleEdit = useCallback(() => {
setShowEdit(true);
}, []);
const onSetLink = useCallback(
(url: string, internal?: boolean) => {
(url: string) => {
editor
.chain()
.focus()
.setLink({
href: internal ? url : normalizeUrl(url),
internal: !!internal,
} as any)
.extendMarkRange("link")
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowLinkMenu(false);
setShowEdit(false);
},
[editor, setShowLinkMenu],
[editor],
);
useEffect(() => {
if (!showLinkMenu) return;
const onUnsetLink = useCallback(() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setShowEdit(false);
return null;
}, [editor]);
const dismiss = () => {
setShowLinkMenu(false);
editor.commands.focus();
editor.commands.setTextSelection(editor.state.selection.to);
};
const onShowEdit = useCallback(() => {
setShowEdit(true);
}, []);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
dismiss();
}
};
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
dismiss();
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [showLinkMenu, setShowLinkMenu]);
if (!showLinkMenu) return null;
const onHideEdit = useCallback(() => {
setShowEdit(false);
}, []);
return (
<BubbleMenu
<BaseBubbleMenu
editor={editor}
shouldShow={({ editor, state }) => {
const { empty } = state.selection;
return (
showLinkMenuRef.current &&
editor.isEditable &&
!empty &&
isTextSelected(editor)
);
}}
pluginKey={`link-menu`}
updateDelay={0}
options={{
placement: "bottom",
offset: 8,
onShow: focusInput,
onHide: () => {
setShowLinkMenu(false);
setShowEdit(false);
},
placement: "bottom",
offset: 5,
// zIndex: 101,
}}
style={{ zIndex: 198, position: "relative" }}
shouldShow={shouldShow}
>
<Paper ref={containerRef} w={320} p="sm" shadow="md" radius={6} withBorder>
<LinkEditorPanel onSetLink={onSetLink} />
</Paper>
</BubbleMenu>
{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;
@@ -0,0 +1,60 @@
import {
Tooltip,
ActionIcon,
Card,
Divider,
Anchor,
Flex,
} from "@mantine/core";
import { IconLinkOff, IconPencil } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./link.module.css";
export type LinkPreviewPanelProps = {
url: string;
onEdit: () => void;
onClear: () => void;
};
export const LinkPreviewPanel = ({
onClear,
onEdit,
url,
}: LinkPreviewPanelProps) => {
const { t } = useTranslation();
return (
<>
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
<Flex align="center">
<Tooltip label={url}>
<Anchor
href={url}
target="_blank"
rel="noopener noreferrer"
className={classes.link}
>
{url}
</Anchor>
</Tooltip>
<Flex align="center">
<Divider mx={4} orientation="vertical" />
<Tooltip label={t("Edit link")}>
<ActionIcon onClick={onEdit} variant="subtle" color="gray">
<IconPencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Remove link")}>
<ActionIcon onClick={onClear} variant="subtle" color="red">
<IconLinkOff size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
</Card>
</>
);
};
@@ -1,583 +0,0 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import {
IconFileDescription,
IconCopy,
IconExternalLink,
IconLinkOff,
IconPencil,
IconWorld,
} from "@tabler/icons-react";
import { useState, useCallback, useRef, useEffect } from "react";
import { notifications } from "@mantine/notifications";
import {
Divider,
Group,
Popover,
Text,
TextInput,
ActionIcon,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import classes from "./link.module.css";
import { useTranslation } from "react-i18next";
import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard } from "@docmost/editor-ext";
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,102 +1,6 @@
.linkWrapper {
position: relative;
display: inline;
}
.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);
}
}
}
.link {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,5 +1,4 @@
export type LinkEditorPanelProps = {
initialUrl?: string;
onSetLink: (url: string, internal?: boolean) => void;
onUnsetLink?: () => void;
onSetLink: (url: string, openInNewTab?: boolean) => void;
};
@@ -13,16 +13,11 @@ export const useLinkEditorState = ({
const isValidUrl = useMemo(() => /^(\S+):(\/\/)?\S+$/.test(url), [url]);
const isSearchQuery = useMemo(
() => url.length > 0 && !isValidUrl,
[url, isValidUrl],
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (isValidUrl) {
onSetLink(url, false);
onSetLink(url);
}
},
[url, isValidUrl, onSetLink],
@@ -34,6 +29,5 @@ export const useLinkEditorState = ({
onChange,
handleSubmit,
isValidUrl,
isSearchQuery,
};
};
@@ -86,9 +86,8 @@ import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts";
import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
import { ReactNodeViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import LinkView from "@/features/editor/components/link/link-view.tsx";
import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
@@ -177,10 +176,6 @@ export const mainExtensions = [
}),
LinkExtension.configure({
openOnClick: false,
}).extend({
addMarkView() {
return ReactMarkViewRenderer(LinkView);
},
}),
Superscript,
SubScript,
@@ -42,7 +42,7 @@ export const useEditorScroll = ({
return;
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true);
@@ -50,6 +50,7 @@ import {
handleFileDrop,
handlePaste,
} 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 DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
@@ -66,7 +67,6 @@ import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
interface PageEditorProps {
@@ -408,7 +408,6 @@ export default function PageEditor({
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
@@ -419,6 +418,7 @@ export default function PageEditor({
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
@@ -98,12 +98,12 @@
a {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
@mixin light {
border-bottom: 0.07em solid var(--mantine-color-dark-0);
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.07em solid var(--mantine-color-dark-2);
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
font-weight: 500;
/*font-weight: 500; */
text-decoration: none;
cursor: pointer;
}
@@ -223,13 +223,13 @@
.ProseMirror > h4,
.ProseMirror > h5,
.ProseMirror > h6 {
> .link-btn {
cursor: pointer;
position: relative;
}
> .link-btn > .link-btn-content {
opacity: 0;
position: absolute;
@@ -241,7 +241,7 @@
justify-content: center;
flex-direction: column;
}
&:hover > .link-btn > .link-btn-content {
opacity: 1;
}
@@ -12,7 +12,7 @@ import {
} from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query";
@@ -36,20 +36,20 @@ export function NotificationItem({
const isUnread = !notification.readAt;
const getNotificationMessageKey = (): string => {
const getNotificationMessage = (): string => {
switch (notification.type) {
case "comment.user_mention":
return "<bold>{{name}}</bold> mentioned you in a comment";
return t("mentioned you in a comment");
case "comment.created":
return "<bold>{{name}}</bold> commented on a page";
return t("commented on a page");
case "comment.resolved":
return "<bold>{{name}}</bold> resolved a comment";
return t("resolved a comment");
case "page.user_mention":
return "<bold>{{name}}</bold> mentioned you on a page";
return t("mentioned you on a page");
case "page.permission_granted":
return notification.data?.role === "writer"
? "<bold>{{name}}</bold> gave you edit access to a page"
: "<bold>{{name}}</bold> gave you view access to a page";
? t("gave you edit access to a page")
: t("gave you view access to a page");
default:
return "";
}
@@ -95,11 +95,10 @@ export function NotificationItem({
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={2}>
<Trans
i18nKey={getNotificationMessageKey()}
values={{ name: notification.actor?.name }}
components={{ bold: <Text span fw={600} /> }}
/>
<Text span fw={600}>
{notification.actor?.name}
</Text>{" "}
{getNotificationMessage()}
</Text>
{notification.page && (
@@ -28,11 +28,9 @@ import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit } from "@/lib/config.ts";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
import { queryClient } from "@/main.tsx";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@@ -84,6 +82,7 @@ interface ImportFormatSelection {
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const [workspace] = useAtom(workspaceAtom);
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
const emit = useQueryEmit();
@@ -94,9 +93,8 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null);
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
const upgradeLabel = useUpgradeLabel();
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const canUseDocx = isCloud() || workspace?.hasLicenseKey;
const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) {
@@ -362,7 +360,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
>
{(props) => (
<Tooltip
label={upgradeLabel}
label={t("Available in enterprise edition")}
disabled={canUseDocx}
>
<Button
@@ -401,7 +399,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
>
{(props) => (
<Tooltip
label={upgradeLabel}
label={t("Available in enterprise edition")}
disabled={canUseConfluence}
>
<Button
@@ -22,9 +22,9 @@ import {
import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
@@ -42,7 +42,7 @@ export function SearchSpotlightFilters({
isAiMode = false,
}: SearchSpotlightFiltersProps) {
const { t } = useTranslation();
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const { hasLicenseKey } = useLicense();
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null,
);
@@ -87,7 +87,7 @@ export function SearchSpotlightFilters({
{
value: "attachment",
label: t("Attachments"),
disabled: !hasAttachmentIndexing,
disabled: !isCloud() && !hasLicenseKey,
},
];
@@ -11,16 +11,15 @@ import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useLicense } from "@/ee/hooks/use-license.tsx";
import { isCloud } from "@/lib/config.ts";
interface SearchSpotlightProps {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const { hasLicenseKey } = useLicense();
const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const [filters, setFilters] = useState<{
@@ -85,7 +84,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
// Determine result type for rendering
const isAttachmentSearch =
filters.contentType === "attachment" && hasAttachmentIndexing;
filters.contentType === "attachment" && (hasLicenseKey || isCloud());
const resultItems = (searchResults || []).map((result) => (
<SearchResultItem
@@ -135,7 +134,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}
}}
/>
{isAiMode && hasAiFeature && (
{isAiMode && hasLicenseKey && (
<Button
size="xs"
leftSection={<IconSparkles size={16} />}
@@ -8,8 +8,8 @@ import {
IPageSearch,
IPageSearchParams,
} from "@/features/search/types/search.types";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useLicense } from "@/ee/hooks/use-license";
import { isCloud } from "@/lib/config";
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
@@ -21,10 +21,10 @@ export function useUnifiedSearch(
params: UseUnifiedSearchParams,
enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> {
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const { hasLicenseKey } = useLicense();
const isAttachmentSearch =
params.contentType === "attachment" && hasAttachmentIndexing;
params.contentType === "attachment" && (isCloud() || hasLicenseKey);
const searchType = isAttachmentSearch ? "attachment" : "page";
return useQuery({
@@ -1,4 +1,4 @@
import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-query";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
searchAttachments,
searchPage,
@@ -32,7 +32,6 @@ export function useSearchSuggestionsQuery(
staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(queryParams),
enabled: preload || !!params.query,
placeholderData: keepPreviousData,
});
}
@@ -180,7 +180,7 @@ export default function ShareShell({
<AppShell.Main>
{children}
{data && shareId && !(data.features?.length > 0) && <ShareBranding />}
{data && shareId && !data.hasLicenseKey && <ShareBranding />}
</AppShell.Main>
<AppShell.Aside
@@ -41,7 +41,7 @@ export interface ISharedPage extends IShare {
level: number;
sharedPage: { id: string; slugId: string; title: string; icon: string };
};
features?: string[];
hasLicenseKey: boolean;
}
export interface IShareForPage extends IShare {
@@ -71,5 +71,5 @@ export interface IShareInfoInput {
export interface ISharedPageTree {
share: IShare;
pageTree: Partial<IPage[]>;
features?: string[];
hasLicenseKey: boolean;
}
@@ -19,6 +19,7 @@ import {
ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
interface SpaceDetailsProps {
spaceId: string;
@@ -27,7 +28,8 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const showSharingToggle = !readOnly;
const hasEnterpriseAccess = useEnterpriseAccess();
const showSharingToggle = !readOnly && hasEnterpriseAccess;
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false);
@@ -1,4 +1,4 @@
import { useAtom, useSetAtom } from "jotai";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user";
@@ -11,14 +11,10 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.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) {
const [, setCurrentUser] = useAtom(currentUserAtom);
const setEntitlements = useSetAtom(entitlementAtom);
const { data, isLoading, error, isError } = useCurrentUser();
const { data: entitlements } = useEntitlements();
const { i18n } = useTranslation();
const [, setSocket] = useAtom(socketAtom);
// fetch collab token on load
@@ -60,12 +56,6 @@ export function UserProvider({ children }: React.PropsWithChildren) {
}
}, [data, isLoading]);
useEffect(() => {
if (entitlements) {
setEntitlements(entitlements);
}
}, [entitlements]);
if (isLoading) return <></>;
if (isError && error?.["response"]?.status === 404) {
@@ -113,7 +113,7 @@ export async function getInvitationById(data: {
export async function createWorkspace(
data: ISetupWorkspace,
): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> {
): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> {
const req = await api.post("/workspace/create", data);
return req.data;
}
@@ -20,6 +20,7 @@ export interface IWorkspace {
emailDomains: string[];
memberCount?: number;
plan?: string;
hasLicenseKey?: boolean;
enforceMfa?: boolean;
aiSearch?: boolean;
generativeAi?: boolean;
@@ -83,6 +84,7 @@ export interface IPublicWorkspace {
hostname: string;
enforceSso: boolean;
authProviders: IAuthProvider[];
hasLicenseKey?: boolean;
}
export interface IVersion {
@@ -0,0 +1,7 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};
-1
View File
@@ -12,7 +12,6 @@ const APP_ROUTE = {
SELECT_WORKSPACE: "/select",
MFA_CHALLENGE: "/login/mfa",
MFA_SETUP_REQUIRED: "/login/mfa/setup",
VERIFY_EMAIL: "/verify-email",
},
SETTINGS: {
ACCOUNT: {
+1 -1
View File
@@ -68,7 +68,7 @@ export default function SharedPage() {
/>
</Container>
{data && !shareId && !(data.features?.length > 0) && <ShareBranding />}
{data && !shareId && !data.hasLicenseKey && <ShareBranding />}
</div>
);
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.70.3",
"version": "0.70.1",
"description": "",
"author": "",
"private": true,
@@ -107,7 +107,6 @@
"sanitize-filename-ts": "1.0.2",
"socket.io": "^4.8.3",
"stripe": "^17.5.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^2.1.0",
+9
View File
@@ -91,6 +91,15 @@ export function extractBearerTokenFromHeader(
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.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
@@ -1,142 +0,0 @@
import { containsDomain } from './no-urls.validator';
// containsDomain returns true if value contains a domain-like pattern
// The full NoUrls validator also checks for https:// URLs separately
describe('containsDomain', () => {
describe('bare domains with real TLDs — should block', () => {
it.each([
'example.com',
'example.net',
'example.org',
'example.io',
'example.co',
'example.dev',
'example.app',
'example.me',
'example.info',
'example.tech',
'example.aero',
'example.cloud',
'example.museum',
'example.abc',
'example.uk',
'example.de',
'example.fr',
'example.ru',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domains with paths — should block', () => {
it.each([
'example.com/reset',
'example.com/reset-password',
'click example.com/page',
'go to example.net/login',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('multi-part domains — should block', () => {
it.each([
'Foo.com.net',
'Foo.com.',
'Foo.mine.net',
'Foo.mine.ne',
'sub.example.com',
'login.example.co.uk',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domain in sentence — should block', () => {
it.each([
'Reset your password at example.com',
'URGENT click example.com/reset',
'Visit example.org for details',
'go to mysite.io now',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('case insensitive — should block', () => {
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('fake TLDs — should allow', () => {
it.each([
'Foo.mine',
'Foo.blarg',
'Foo.qqq',
'Foo.zz',
'Foo.abcd',
'Foo.abcde',
'Foo.abcdef',
'Foo.abcdefg',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('too short suffix — should allow', () => {
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('multi-part with fake TLD — should allow', () => {
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('emails — should allow', () => {
it.each([
'user@example.com',
'admin@company.org',
'test@sub.domain.co.uk',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('normal names — should allow', () => {
it.each([
'John Smith',
'Dr. Smith',
'A. B. Charlie',
'John',
'Mary Jane',
"O'Brien",
'Jean-Pierre',
'José García',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('IP addresses — should allow', () => {
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
describe('edge cases — should allow', () => {
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
});
@@ -1,42 +0,0 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import * as tlds from 'tlds';
const URL_PATTERN = /https?:\/\//i;
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
export function containsDomain(value: string): boolean {
const tokens = value.split(/\s+/);
for (const token of tokens) {
if (token.includes('@')) continue;
const segments = token.split('.');
for (let i = 1; i < segments.length; i++) {
const suffix = segments[i].replace(/[^\w].*/g, '');
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
return true;
}
}
}
return false;
}
export function NoUrls(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'noUrls',
target: object.constructor,
propertyName,
options: {
message: 'Must not contain URLs or domain names',
...validationOptions,
},
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return true;
if (URL_PATTERN.test(value)) return false;
if (containsDomain(value)) return false;
return true;
},
},
});
};
}
@@ -1,4 +1,3 @@
export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
}
-32
View File
@@ -1,37 +1,5 @@
import { BadRequestException } from '@nestjs/common';
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) {
if (workspace.enforceSso) {
@@ -7,13 +7,11 @@ import {
} from 'class-validator';
import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty()
@MinLength(1)
@MaxLength(50)
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -7,14 +7,12 @@ import {
MinLength,
} from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto {
@IsOptional()
@MinLength(1)
@MaxLength(50)
@IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim())
name: string;
@@ -17,7 +17,6 @@ import {
isUserDisabled,
nanoIdGen,
} from '../../../common/helpers';
import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -37,7 +36,6 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable()
export class AuthService {
@@ -48,7 +46,6 @@ export class AuthService {
private userTokenRepo: UserTokenRepo,
private mailService: MailService,
private domainService: DomainService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -72,14 +69,6 @@ export class AuthService {
throw new UnauthorizedException(errorMessage);
}
throwIfEmailNotVerified({
isCloud: this.environmentService.isCloud(),
emailVerifiedAt: user.emailVerifiedAt,
email: user.email,
workspaceId,
appSecret: this.environmentService.getAppSecret(),
});
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
@@ -258,14 +247,6 @@ export class AuthService {
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
const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false;
@@ -91,15 +91,9 @@ export class SearchService {
return { items: [] };
}
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
if (isRestricted) {
return { items: [] };
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendantsExcludingRestricted(
const pageList = await this.pageRepo.getPageAndDescendants(
share.pageId,
{
includeContent: false,
+13 -10
View File
@@ -28,7 +28,8 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { hasLicenseOrEE } from '../../common/helpers';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
@@ -44,7 +45,7 @@ export class ShareController {
private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
private readonly licenseCheckService: LicenseCheckService,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -80,10 +81,11 @@ export class ShareController {
return {
...shareData,
features: this.licenseCheckService.resolveFeatures(
workspace.licenseKey,
workspace.plan,
),
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
plan: workspace.plan,
}),
};
}
@@ -257,10 +259,11 @@ export class ShareController {
return {
...treeData,
features: this.licenseCheckService.resolveFeatures(
workspace.licenseKey,
workspace.plan,
),
hasLicenseKey: hasLicenseOrEE({
licenseKey: workspace.licenseKey,
isCloud: this.environmentService.isCloud(),
plan: workspace.plan,
}),
};
}
}
@@ -139,10 +139,10 @@ export class SpaceService {
});
if (
!this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan)
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
) {
throw new ForbiddenException(
'This feature requires a valid license',
'This feature requires a valid enterprise license',
);
}
}
@@ -37,6 +37,7 @@ export class UserController {
const workspaceInfo = {
...rest,
memberCount,
hasLicenseKey: Boolean(licenseKey),
};
return { user: authUser, workspace: workspaceInfo };
@@ -32,10 +32,8 @@ import {
} from '../../casl/interfaces/workspace-ability.type';
import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@UseGuards(JwtAuthGuard)
@Controller('workspace')
@@ -44,9 +42,7 @@ export class WorkspaceController {
private readonly workspaceService: WorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService,
private licenseCheckService: LicenseCheckService,
) {}
@Public()
@@ -62,23 +58,6 @@ export class WorkspaceController {
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)
@Post('update')
async updateWorkspace(
@@ -12,7 +12,6 @@ import {
MinLength,
} from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto {
@IsArray()
@@ -45,7 +44,6 @@ export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2)
@MaxLength(60)
@IsString()
@NoUrls()
name: string;
@MinLength(8)
@@ -85,7 +85,7 @@ export class WorkspaceService {
async getWorkspacePublicData(workspaceId: string) {
const workspace = await this.db
.selectFrom('workspaces')
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey', 'plan'])
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey'])
.select((eb) =>
jsonArrayFrom(
eb
@@ -106,9 +106,12 @@ export class WorkspaceService {
throw new NotFoundException('Workspace not found');
}
const { licenseKey, plan, ...rest } = workspace;
const { licenseKey, ...rest } = workspace;
return rest;
return {
...rest,
hasLicenseKey: Boolean(licenseKey),
};
}
async create(
@@ -241,7 +244,7 @@ export class WorkspaceService {
await this.billingQueue.add(
QueueJob.WELCOME_EMAIL,
{ userId: user.id },
{ delay: 30 * 60 * 1000 }, // 30m
{ delay: 60 * 1000 }, // 1m
);
} catch (err) {
this.logger.error(err);
@@ -329,32 +332,14 @@ export class WorkspaceService {
) {
const ws = await this.db
.selectFrom('workspaces')
.select(['id', 'licenseKey', 'plan', 'trashRetentionDays'])
.select(['id', 'licenseKey', 'trashRetentionDays'])
.where('id', '=', workspaceId)
.executeTakeFirst();
if (!ws) {
throw new NotFoundException('Workspace not found');
}
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 (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) {
throw new ForbiddenException(
'This feature requires a valid enterprise license',
);
}
if (
@@ -518,7 +503,10 @@ export class WorkspaceService {
}
const { licenseKey, ...rest } = workspace;
return rest;
return {
...rest,
hasLicenseKey: Boolean(licenseKey),
};
}
async getWorkspaceUsers(
@@ -10,7 +10,7 @@ import {
validateSync,
} from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { IsISO6391 } from '../../common/validators/is-iso6391';
import { IsISO6391 } from '../../common/validator/is-iso6391';
export class EnvironmentVariables {
@IsNotEmpty()
@@ -25,75 +25,4 @@ export class LicenseCheckService {
return false;
}
}
hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
if (this.environmentService.isCloud()) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
return getFeaturesForCloudPlan(plan).has(feature);
} catch {
return false;
}
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.hasFeature(licenseKey, feature);
} catch {
return false;
}
}
getFeatures(licenseKey: string): string[] {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.getFeatures(licenseKey);
} catch {
return [];
}
}
resolveFeatures(licenseKey: string, plan: string): string[] {
if (this.environmentService.isCloud()) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
return [...getFeaturesForCloudPlan(plan)];
} catch {
return [];
}
}
return this.getFeatures(licenseKey);
}
resolveTier(licenseKey: string, plan: string): string {
if (this.environmentService.isCloud()) {
return plan ?? 'standard';
}
return this.getLicenseType(licenseKey) ?? 'free';
}
private getLicenseType(licenseKey: string): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.getLicenseType(licenseKey);
} catch {
return null;
}
}
}
@@ -291,7 +291,6 @@ export class ExportService {
prosemirrorJson,
slugIdToPath,
currentPagePath,
baseUrl,
);
if (includeAttachments) {
@@ -62,7 +62,6 @@ export function replaceInternalLinks(
prosemirrorJson: any,
slugIdToPath: Record<string, string>,
currentPagePath: string,
baseUrl?: string,
) {
const doc = jsonToNode(prosemirrorJson);
@@ -77,10 +76,6 @@ export function replaceInternalLinks(
const localPath = slugIdToPath[slugId];
if (!localPath) {
if (baseUrl && mark.attrs.href.startsWith('/')) {
//@ts-expect-error
mark.attrs.href = `${baseUrl}${mark.attrs.href}`;
}
continue;
}
@@ -25,7 +25,6 @@ import {
buildAttachmentCandidates,
collectMarkdownAndHtmlFiles,
encodeFilePath,
extractNotionPartialId,
readDocmostMetadata,
stripNotionID,
} from '../utils/import.utils';
@@ -161,17 +160,10 @@ export class FileImportTaskService {
fileTask: FileTask;
}): Promise<void> {
const { extractDir, fileTask } = opts;
const isNotion = fileTask.source === FileImportSource.Notion;
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
const docmostMetadata = await readDocmostMetadata(extractDir);
const space = await this.db
.selectFrom('spaces')
.select(['slug'])
.where('id', '=', fileTask.spaceId)
.executeTakeFirst();
const pagesMap = new Map<string, ImportPageNode>();
for (const absPath of allFiles) {
@@ -232,17 +224,7 @@ export class FileImportTaskService {
}
// For each folder with content, create a placeholder page if no corresponding .md or .html exists
// Process folders with partial UUIDs first so they claim their specific files
// before plain folders (without partial UUIDs) take whatever remains.
const sortedFolders = isNotion
? [...foldersWithContent].sort((a, b) => {
const aHasPartial = extractNotionPartialId(path.basename(a)) ? 0 : 1;
const bHasPartial = extractNotionPartialId(path.basename(b)) ? 0 : 1;
return aHasPartial - bHasPartial;
})
: [...foldersWithContent];
sortedFolders.forEach((folderPath) => {
foldersWithContent.forEach((folderPath) => {
if (
skipRootFolder &&
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
@@ -255,54 +237,18 @@ export class FileImportTaskService {
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
const folderName = path.basename(folderPath);
const parentDir = path.dirname(folderPath);
// Notion no longer adds UUIDs to folder names, but still adds them to files.
// For duplicate names, Notion adds a partial UUID "{first4}-{last4}" to the folder.
let matched = false;
if (isNotion) {
const partialId = extractNotionPartialId(folderName);
const strippedFolderName = stripNotionID(folderName);
const isSameDir = (fileDir: string) =>
fileDir === parentDir || (parentDir === '.' && !fileDir.includes('/'));
for (const [filePath, page] of pagesMap.entries()) {
if (!isSameDir(path.dirname(filePath))) continue;
if (page.name !== strippedFolderName) continue;
if (partialId) {
// Match partial UUID against the full UUID in the filename
const fileBase = path.basename(filePath, path.extname(filePath));
const fullIdMatch = fileBase.match(/[a-f0-9]{32}$/i);
if (!fullIdMatch) continue;
const fullId = fullIdMatch[0].toLowerCase();
if (!fullId.startsWith(partialId.prefix) || !fullId.endsWith(partialId.suffix)) {
continue;
}
}
pagesMap.delete(filePath);
page.filePath = mdPath;
pagesMap.set(mdPath, page);
matched = true;
break;
}
}
if (!matched) {
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, {
id: v7(),
slugId: generateSlugId(),
name: stripNotionID(folderName),
content: '',
parentPageId: null,
fileExtension: '.md',
filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
});
}
const encodedMdPath = encodeFilePath(mdPath);
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
pagesMap.set(mdPath, {
id: v7(),
slugId: generateSlugId(),
name: stripNotionID(folderName),
content: '',
parentPageId: null,
fileExtension: '.md',
filePath: mdPath,
icon: placeholderMetadata?.icon ?? null,
});
}
});
@@ -512,7 +458,6 @@ export class FileImportTaskService {
creatorId: fileTask.creatorId,
sourcePageId: page.id,
workspaceId: fileTask.workspaceId,
spaceSlug: space?.slug,
});
const pmState = getProsemirrorContent(
@@ -4,8 +4,6 @@ import * as path from 'path';
import { v7 } from 'uuid';
import { InsertableBacklink } from '@docmost/db/types/entity.types';
import { Cheerio, CheerioAPI, load } from 'cheerio';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import slugify = require('@sindresorhus/slugify');
// Check if text contains Unicode characters (for emojis/icons)
function isUnicodeCharacter(text: string): boolean {
@@ -24,7 +22,6 @@ export async function formatImportHtml(opts: {
workspaceId: string;
pageDir?: string;
attachmentCandidates?: string[];
spaceSlug?: string;
}): Promise<{
html: string;
backlinks: InsertableBacklink[];
@@ -64,7 +61,6 @@ export async function formatImportHtml(opts: {
creatorId,
sourcePageId,
workspaceId,
opts.spaceSlug,
);
return {
@@ -320,7 +316,6 @@ export async function rewriteInternalLinksToMentionHtml(
creatorId: string,
sourcePageId: string,
workspaceId: string,
spaceSlug?: string,
): Promise<InsertableBacklink[]> {
const normalize = (p: string) => p.replace(/\\/g, '/');
const backlinks: InsertableBacklink[] = [];
@@ -344,37 +339,19 @@ export async function rewriteInternalLinksToMentionHtml(
);
const meta = filePathToPageMetaMap.get(resolved);
if (!meta) return;
const linkText = $a.text().trim();
const titleMatch =
linkText === meta.title ||
linkText === meta.title?.trim();
if (titleMatch) {
const mentionId = v7();
const $mention = $('<span>')
.attr({
'data-type': 'mention',
'data-id': mentionId,
'data-entity-type': 'page',
'data-entity-id': meta.id,
'data-label': meta.title,
'data-slug-id': meta.slugId,
'data-creator-id': creatorId,
})
.text(meta.title);
$a.replaceWith($mention);
} else {
const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled');
const pageSlug = `${titleSlug}-${meta.slugId}`;
const internalHref = spaceSlug
? `/s/${spaceSlug}/p/${pageSlug}`
: `/p/${pageSlug}`;
$a.attr('href', internalHref);
$a.attr('data-internal', 'true');
}
const mentionId = v7();
const $mention = $('<span>')
.attr({
'data-type': 'mention',
'data-id': mentionId,
'data-entity-type': 'page',
'data-entity-id': meta.id,
'data-label': meta.title,
'data-slug-id': meta.slugId,
'data-creator-id': creatorId,
})
.text(meta.title);
$a.replaceWith($mention);
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
});
@@ -81,25 +81,7 @@ export async function collectMarkdownAndHtmlFiles(
export function stripNotionID(fileName: string): string {
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
// Handle partial UUID format used for duplicate names: "Name abcd-ef12"
const partialIdPattern = / [a-f0-9]{4}-[a-f0-9]{4}$/i;
return fileName
.replace(notionIdPattern, '')
.replace(partialIdPattern, '')
.trim();
}
/**
* Extract a partial Notion UUID suffix from a folder name.
* Notion adds "{first4}-{last4}" when multiple pages share the same title.
* e.g. "Cool 324d-35ab" → { prefix: "324d", suffix: "35ab" }
*/
export function extractNotionPartialId(
folderName: string,
): { prefix: string; suffix: string } | null {
const match = folderName.match(/ ([a-f0-9]{4})-([a-f0-9]{4})$/i);
if (!match) return null;
return { prefix: match[1].toLowerCase(), suffix: match[2].toLowerCase() };
return fileName.replace(notionIdPattern, '').trim();
}
export function encodeFilePath(filePath: string): string {
-1
View File
@@ -67,7 +67,6 @@ async function bootstrap() {
'/api/sso/google',
'/api/workspace/create',
'/api/workspace/joined',
'/api/workspace/find-by-email',
];
if (
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.70.3",
"version": "0.70.1",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -62,7 +62,7 @@
"cross-env": "^7.0.3",
"date-fns": "^4.1.0",
"diff": "8.0.3",
"dompurify": "^3.3.3",
"dompurify": "^3.3.1",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
@@ -109,7 +109,7 @@
"lodash": "4.17.23",
"ws": "8.19.0",
"cross-spawn": "7.0.5",
"dompurify": "3.3.3",
"dompurify": "3.3.1",
"tmp": "0.2.5",
"lodash-es": "4.17.23",
"markdown-it": "14.1.1",
-13
View File
@@ -6,19 +6,6 @@ import { EditorView } from "@tiptap/pm/view";
export const LinkExtension = TiptapLink.extend({
inclusive: false,
addAttributes() {
return {
...this.parent?.(),
internal: {
default: false,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-internal') === 'true',
renderHTML: (attributes) =>
attributes.internal ? { 'data-internal': 'true' } : {},
},
};
},
parseHTML() {
return [
{

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