feat: feature flag upgrade

This commit is contained in:
Philipinho
2026-03-07 21:57:14 +00:00
parent 66c26af34b
commit 73ed0c54e5
41 changed files with 415 additions and 303 deletions
@@ -439,7 +439,6 @@
"Toggle space public sharing": "Toggle space public sharing", "Toggle space public sharing": "Toggle space public sharing",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level", "Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.", "Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Requires an enterprise license": "Requires an enterprise license",
"Page permissions": "Page permissions", "Page permissions": "Page permissions",
"Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.", "Control who can view and edit individual pages. Available with an enterprise license.": "Control who can view and edit individual pages. Available with an enterprise license.",
"Enable public sharing": "Enable public sharing", "Enable public sharing": "Enable public sharing",
@@ -621,14 +620,16 @@
"Generative AI (Ask AI)": "Generative AI (Ask AI)", "Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.", "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI", "Toggle generative AI": "Toggle generative AI",
"Enterprise feature": "Enterprise feature", "Upgrade your plan": "Upgrade your plan",
"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.", "Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
"AI features require a paid plan. Visit docmost.com for more information.": "AI features require a paid plan. Visit docmost.com for more information.",
"AI & MCP": "AI & MCP", "AI & MCP": "AI & MCP",
"AI": "AI", "AI": "AI",
"MCP": "MCP", "MCP": "MCP",
"Model Context Protocol (MCP)": "Model Context Protocol (MCP)", "Model Context Protocol (MCP)": "Model Context Protocol (MCP)",
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "MCP requires a paid plan. Visit docmost.com for more information.": "MCP requires a paid plan. Visit docmost.com for more information.",
"MCP documentation": "MCP documentation", "MCP documentation": "MCP documentation",
"MCP Server URL": "MCP Server URL", "MCP Server URL": "MCP Server URL",
"Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.", "Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
@@ -21,7 +21,9 @@ import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { import {
prefetchApiKeyManagement, prefetchApiKeyManagement,
prefetchApiKeys, prefetchApiKeys,
@@ -39,22 +41,19 @@ import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sideb
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation"; import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
interface DataItem { type DataItem = {
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
path: string; path: string;
isCloud?: boolean; feature?: string;
isEnterprise?: boolean; role?: "admin" | "owner";
isAdmin?: boolean; env?: "cloud" | "selfhosted";
isOwner?: boolean; };
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
interface DataGroup { type DataGroup = {
heading: string; heading: string;
items: DataItem[]; items: DataItem[];
} };
const groupedData: DataGroup[] = [ const groupedData: DataGroup[] = [
{ {
@@ -70,9 +69,7 @@ const groupedData: DataGroup[] = [
label: "API keys", label: "API keys",
icon: IconKey, icon: IconKey,
path: "/settings/account/api-keys", path: "/settings/account/api-keys",
isCloud: true, feature: Feature.API_KEYS,
isEnterprise: true,
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -80,26 +77,20 @@ const groupedData: DataGroup[] = [
heading: "Workspace", heading: "Workspace",
items: [ items: [
{ label: "General", icon: IconSettings, path: "/settings/workspace" }, { label: "General", icon: IconSettings, path: "/settings/workspace" },
{ { label: "Members", icon: IconUsers, path: "/settings/members" },
label: "Members",
icon: IconUsers,
path: "/settings/members",
},
{ {
label: "Billing", label: "Billing",
icon: IconCoin, icon: IconCoin,
path: "/settings/billing", path: "/settings/billing",
isCloud: true, role: "admin",
isAdmin: true, env: "cloud",
}, },
{ {
label: "Security & SSO", label: "Security & SSO",
icon: IconLock, icon: IconLock,
path: "/settings/security", path: "/settings/security",
isCloud: true, feature: Feature.SECURITY_SETTINGS,
isEnterprise: true, role: "admin",
isAdmin: true,
showDisabledInNonEE: true,
}, },
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
@@ -108,25 +99,22 @@ const groupedData: DataGroup[] = [
label: "API management", label: "API management",
icon: IconKey, icon: IconKey,
path: "/settings/api-keys", path: "/settings/api-keys",
isCloud: true, feature: Feature.API_KEYS,
isEnterprise: true, role: "admin",
isAdmin: true,
showDisabledInNonEE: true,
}, },
{ {
label: "AI settings", label: "AI settings",
icon: IconSparkles, icon: IconSparkles,
path: "/settings/ai", path: "/settings/ai",
isAdmin: true, role: "admin",
}, },
{ {
label: "Audit log", label: "Audit log",
icon: IconHistory, icon: IconHistory,
path: "/settings/audit", path: "/settings/audit",
isEnterprise: true, feature: Feature.AUDIT_LOGS,
isOwner: true, role: "owner",
isSelfhosted: true, env: "selfhosted",
showDisabledInNonEE: true,
}, },
], ],
}, },
@@ -149,6 +137,7 @@ export default function SettingsSidebar() {
const { goBack } = useSettingsNavigation(); const { goBack } = useSettingsNavigation();
const { isAdmin, isOwner } = useUserRole(); const { isAdmin, isOwner } = useUserRole();
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const upgradeLabel = useUpgradeLabel();
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -156,43 +145,20 @@ export default function SettingsSidebar() {
setActive(location.pathname); setActive(location.pathname);
}, [location.pathname]); }, [location.pathname]);
const hasRoleAccess = (item: DataItem) => { const hasFeature = (f: string) =>
if (item.isOwner) return isOwner; workspace?.features?.includes(f) ?? false;
if (item.isAdmin) return isAdmin;
const canShowItem = (item: DataItem) => {
if (item.env === "cloud" && !isCloud()) return false;
if (item.env === "selfhosted" && isCloud()) return false;
if (item.role === "admin" && !isAdmin) return false;
if (item.role === "owner" && !isOwner) return false;
return true; return true;
}; };
const canShowItem = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) {
if (item.isSelfhosted && isCloud()) return false;
return hasRoleAccess(item);
}
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return hasRoleAccess(item);
}
if (item.isCloud) {
return isCloud() ? hasRoleAccess(item) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? hasRoleAccess(item) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? hasRoleAccess(item) : false;
}
return hasRoleAccess(item);
};
const isItemDisabled = (item: DataItem) => { const isItemDisabled = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) { if (!item.feature) return false;
return !(isCloud() || workspace?.hasLicenseKey); return !hasFeature(item.feature);
}
return false;
}; };
const menuItems = groupedData.map((group) => { const menuItems = groupedData.map((group) => {
@@ -280,7 +246,7 @@ export default function SettingsSidebar() {
return ( return (
<Tooltip <Tooltip
key={item.label} key={item.label}
label={t("Available in enterprise edition")} label={upgradeLabel}
position="right" position="right"
withArrow withArrow
> >
@@ -1,12 +1,13 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core"; import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import useLicense from "@/ee/hooks/use-license.tsx"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiSearch() { export default function EnableAiSearch() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -37,9 +38,8 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search); const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -56,14 +56,16 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
}; };
return ( return (
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
size={size} <Switch
label={label} size={size}
labelPosition="left" label={label}
defaultChecked={checked} labelPosition="left"
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
aria-label={t("Toggle AI search")} disabled={!hasAccess}
/> aria-label={t("Toggle AI search")}
/>
</Tooltip>
); );
} }
@@ -1,17 +1,20 @@
import { Group, Text, Switch } from "@mantine/core"; import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableGenerativeAi() { export default function EnableGenerativeAi() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative); const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -38,11 +41,13 @@ export default function EnableGenerativeAi() {
</Text> </Text>
</div> </div>
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
defaultChecked={checked} <Switch
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
/> disabled={!hasAccess}
/>
</Tooltip>
</Group> </Group>
); );
} }
@@ -16,7 +16,9 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts"; import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx"; import { CopyButton } from "@/components/common/copy-button.tsx";
@@ -25,7 +27,8 @@ export default function McpSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp); const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const mcpUrl = `${getAppUrl()}/mcp`; const mcpUrl = `${getAppUrl()}/mcp`;
@@ -46,13 +49,9 @@ export default function McpSettings() {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{!hasAccess && ( {!hasAccess && (
<Alert <Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
>
{t( {t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "MCP requires a paid plan. Visit docmost.com for more information.",
)} )}
</Alert> </Alert>
)} )}
@@ -76,11 +75,13 @@ export default function McpSettings() {
</Text> </Text>
</div> </div>
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
defaultChecked={checked} <Switch
onChange={handleChange} defaultChecked={checked}
disabled={!hasAccess} onChange={handleChange}
/> disabled={!hasAccess}
/>
</Tooltip>
</Group> </Group>
{checked && ( {checked && (
@@ -89,11 +90,7 @@ export default function McpSettings() {
{t("MCP Server URL")} {t("MCP Server URL")}
</Text> </Text>
<Group gap="xs"> <Group gap="xs">
<TextInput <TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<CopyButton value={mcpUrl} timeout={2000}> <CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => ( {({ copied, copy }) => (
<Tooltip <Tooltip
@@ -123,12 +120,36 @@ export default function McpSettings() {
{t("Supported tools")} {t("Supported tools")}
</Text> </Text>
<List size="sm" spacing={2}> <List size="sm" spacing={2}>
<List.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</Text></List.Item> <List.Item>
<List.Item><Text size="sm" c="dimmed" span>list_pages, list_child_pages, duplicate_page</Text></List.Item> <Text size="sm" c="dimmed" span>
<List.Item><Text size="sm" c="dimmed" span>copy_page_to_space, move_page, move_page_to_space</Text></List.Item> search_pages, get_page, create_page, update_page
<List.Item><Text size="sm" c="dimmed" span>get_space, list_spaces, create_space, update_space</Text></List.Item> </Text>
<List.Item><Text size="sm" c="dimmed" span>get_comments, create_comment, update_comment</Text></List.Item> </List.Item>
<List.Item><Text size="sm" c="dimmed" span>search_attachments, list_workspace_members, get_current_user</Text></List.Item> <List.Item>
<Text size="sm" c="dimmed" span>
list_pages, list_child_pages, duplicate_page
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
copy_page_to_space, move_page, move_page_to_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_space, list_spaces, create_space, update_space
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
get_comments, create_comment, update_comment
</Text>
</List.Item>
<List.Item>
<Text size="sm" c="dimmed" span>
search_attachments, list_workspace_members, get_current_user
</Text>
</List.Item>
</List> </List>
</div> </div>
</div> </div>
+7 -4
View File
@@ -9,14 +9,17 @@ import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx"; import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core"; import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() { export default function AiSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasAccess = useIsCloudEE(); const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -55,12 +58,12 @@ export default function AiSettings() {
{!hasAccess && ( {!hasAccess && (
<Alert <Alert
icon={<IconInfoCircle />} icon={<IconInfoCircle />}
title={t("Enterprise feature")} title={upgradeLabel}
color="blue" color="blue"
mb="lg" mb="lg"
> >
{t( {t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "AI features require a paid plan. Visit docmost.com for more information.",
)} )}
</Alert> </Alert>
)} )}
@@ -5,12 +5,14 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { import {
ResponsiveSettingsRow, ResponsiveSettingsRow,
ResponsiveSettingsContent, ResponsiveSettingsContent,
ResponsiveSettingsControl, ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row"; } from "@/components/ui/responsive-settings-row";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function RestrictApiToAdmins() { export default function RestrictApiToAdmins() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -18,7 +20,8 @@ export default function RestrictApiToAdmins() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true, workspace?.settings?.api?.restrictToAdmins === true,
); );
const hasAccess = useEnterpriseAccess(); const hasAccess = useHasFeature(Feature.API_KEYS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -51,7 +54,7 @@ export default function RestrictApiToAdmins() {
<ResponsiveSettingsControl> <ResponsiveSettingsControl>
<Tooltip <Tooltip
label={t("Requires an enterprise license")} label={upgradeLabel}
disabled={hasAccess} disabled={hasAccess}
refProp="rootRef" refProp="rootRef"
> >
+1 -2
View File
@@ -6,7 +6,6 @@ import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts"; import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx"; import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() { export default function SsoLogin() {
@@ -57,7 +56,7 @@ export default function SsoLogin() {
/> />
)} )}
{(isCloud() || data.hasLicenseKey) && ( {(data.features?.length > 0) && (
<> <>
<Stack align="stretch" justify="center" gap="sm"> <Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => ( {data.authProviders.map((provider) => (
+17
View File
@@ -0,0 +1,17 @@
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',
} as const;
@@ -1,12 +0,0 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
+12
View File
@@ -0,0 +1,12 @@
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export const useHasFeature = (feature: string): boolean => {
const [workspace] = useAtom(workspaceAtom);
return workspace?.features?.includes(feature) ?? false;
};
export const useHasAnyFeature = (): boolean => {
const [workspace] = useAtom(workspaceAtom);
return (workspace?.features?.length ?? 0) > 0;
};
-9
View File
@@ -1,9 +0,0 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export const useLicense = () => {
const [currentUser] = useAtom(currentUserAtom);
return { hasLicenseKey: currentUser?.workspace?.hasLicenseKey };
};
export default useLicense;
@@ -0,0 +1,16 @@
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { isCloud } from "@/lib/config";
export function useUpgradeLabel(): string {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
if (!isCloud()) {
return workspace?.hasLicenseKey
? t("Upgrade your license tier.")
: t("Available with a paid license");
}
return t("Upgrade your plan");
}
@@ -31,7 +31,8 @@ export default function LicenseDetails() {
<Table.Tr> <Table.Tr>
<Table.Th w={160}>Edition</Table.Th> <Table.Th w={160}>Edition</Table.Th>
<Table.Td> <Table.Td>
Enterprise {license.trial && <Badge color="green">Trial</Badge>} {license.licenseType === "business" ? "Business" : "Enterprise"}{" "}
{license.trial && <Badge color="green">Trial</Badge>}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -1,7 +1,10 @@
export type LicenseType = 'business' | 'enterprise';
export interface ILicenseInfo { export interface ILicenseInfo {
id: string; id: string;
customerName: string; customerName: string;
seatCount: number; seatCount: number;
licenseType: LicenseType;
issuedAt: Date; issuedAt: Date;
expiresAt: Date; expiresAt: Date;
trial: boolean; trial: boolean;
@@ -7,8 +7,9 @@ import { getMfaStatus } from "@/ee/mfa";
import { MfaSetupModal } from "@/ee/mfa"; import { MfaSetupModal } from "@/ee/mfa";
import { MfaDisableModal } from "@/ee/mfa"; import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa"; import { MfaBackupCodesModal } from "@/ee/mfa";
import { isCloud } from "@/lib/config.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import useLicense from "@/ee/hooks/use-license.tsx"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row"; import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
export function MfaSettings() { export function MfaSettings() {
@@ -17,7 +18,8 @@ export function MfaSettings() {
const [setupModalOpen, setSetupModalOpen] = useState(false); const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false); const [disableModalOpen, setDisableModalOpen] = useState(false);
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false); const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
const { hasLicenseKey } = useLicense(); const canUseMfa = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const { data: mfaStatus, isLoading } = useQuery({ const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"], queryKey: ["mfa-status"],
@@ -28,8 +30,6 @@ export function MfaSettings() {
return null; return null;
} }
const canUseMfa = isCloud() || hasLicenseKey;
// Check if MFA is truly enabled // Check if MFA is truly enabled
const isMfaEnabled = mfaStatus?.isEnabled === true; const isMfaEnabled = mfaStatus?.isEnabled === true;
@@ -69,7 +69,7 @@ export function MfaSettings() {
<ResponsiveSettingsControl> <ResponsiveSettingsControl>
{!isMfaEnabled ? ( {!isMfaEnabled ? (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseMfa} disabled={canUseMfa}
> >
<Button <Button
@@ -19,7 +19,8 @@ import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-p
import { PagePermissionTab } from "@/ee/page-permission"; import { PagePermissionTab } from "@/ee/page-permission";
import { PublishTab } from "./publish-tab"; import { PublishTab } from "./publish-tab";
import { useShareForPageQuery } from "@/features/share/queries/share-query"; import { useShareForPageQuery } from "@/features/share/queries/share-query";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { useSpaceQuery } from "@/features/space/queries/space-query"; import { useSpaceQuery } from "@/features/space/queries/space-query";
@@ -33,9 +34,9 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const { pageSlug, spaceSlug } = useParams(); const { pageSlug, spaceSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug); const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const isCloudEE = useIsCloudEE(); const hasPagePermissions = useHasFeature(Feature.PAGE_PERMISSIONS);
const [activeTab, setActiveTab] = useState<string | null>( const [activeTab, setActiveTab] = useState<string | null>(
isCloudEE ? "access" : "publish", hasPagePermissions ? "access" : "publish",
); );
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
@@ -51,7 +52,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const isPubliclyShared = !!share; const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } = const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined); usePageRestrictionInfoQuery(opened && hasPagePermissions ? pageId : undefined);
return ( return (
<> <>
@@ -92,7 +93,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="access"> <Tabs.Panel value="access">
{!isCloudEE ? ( {!hasPagePermissions ? (
<Stack align="center" py="md"> <Stack align="center" py="md">
<IconLock size={20} stroke={1.5} /> <IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}> <Text size="sm" ta="center" fw={500}>
@@ -6,21 +6,23 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function DisablePublicSharing() { export default function DisablePublicSharing() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Group justify="space-between" wrap="nowrap" gap="xl"> <Group justify="space-between" wrap="nowrap" gap="xl">
<div> <div>
<Text size="md">{t("Disable public sharing")}</Text> <Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")} {t("Prevent members from sharing pages publicly.")}
</Text> </Text>
</div> </div>
<DisablePublicSharingToggle /> <DisablePublicSharingToggle />
</Group> </Group>
); );
} }
@@ -31,7 +33,8 @@ function DisablePublicSharingToggle() {
const [checked, setChecked] = useState( const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true, workspace?.settings?.sharing?.disabled === true,
); );
const hasAccess = useEnterpriseAccess(); const hasAccess = useHasFeature(Feature.SECURITY_SETTINGS);
const upgradeLabel = useUpgradeLabel();
const applyChange = async (value: boolean) => { const applyChange = async (value: boolean) => {
try { try {
@@ -72,11 +75,7 @@ function DisablePublicSharingToggle() {
}; };
return ( return (
<Tooltip <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch <Switch
checked={checked} checked={checked}
onChange={handleChange} onChange={handleChange}
@@ -1,10 +1,20 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core"; import {
Group,
Text,
Switch,
MantineSize,
Title,
Tooltip,
} from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnforceMfa() { export default function EnforceMfa() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,6 +43,8 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceMfa); const [checked, setChecked] = useState(workspace?.enforceMfa);
const hasAccess = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -49,13 +61,16 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
}; };
return ( return (
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
size={size} <Switch
label={label} size={size}
labelPosition="left" label={label}
defaultChecked={checked} labelPosition="left"
onChange={handleChange} defaultChecked={checked}
aria-label={t("Toggle MFA enforcement")} onChange={handleChange}
/> disabled={!hasAccess}
aria-label={t("Toggle MFA enforcement")}
/>
</Tooltip>
); );
} }
@@ -1,10 +1,13 @@
import { Group, Text, Switch, MantineSize } from "@mantine/core"; import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnforceSso() { export default function EnforceSso() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,6 +36,8 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceSso); const [checked, setChecked] = useState(workspace?.enforceSso);
const hasAccess = useHasFeature(Feature.SECURITY_SETTINGS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -49,13 +54,16 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
}; };
return ( return (
<Switch <Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
size={size} <Switch
label={label} size={size}
labelPosition="left" label={label}
defaultChecked={checked} labelPosition="left"
onChange={handleChange} defaultChecked={checked}
aria-label={t("Toggle sso enforcement")} onChange={handleChange}
/> disabled={!hasAccess}
aria-label={t("Toggle sso enforcement")}
/>
</Tooltip>
); );
} }
@@ -12,13 +12,18 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type RetentionUnit = "days" | "months" | "years"; type RetentionUnit = "days" | "months" | "years";
const DEFAULT_RETENTION_DAYS = 30; const DEFAULT_RETENTION_DAYS = 30;
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } { function daysToRetention(days: number): {
amount: number;
unit: RetentionUnit;
} {
if (days >= 365 && days % 365 === 0) { if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" }; return { amount: days / 365, unit: "years" };
} }
@@ -36,14 +41,19 @@ function retentionToDays(amount: number, unit: RetentionUnit): number {
export default function TrashRetention() { export default function TrashRetention() {
const { t } = useTranslation(); const { t } = useTranslation();
const hasAccess = useEnterpriseAccess(); const hasAccess = useHasFeature(Feature.SECURITY_SETTINGS);
const upgradeLabel = useUpgradeLabel();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS; const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const parsed = daysToRetention(currentDays); const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount); const [retentionAmount, setRetentionAmount] = useState<number | string>(
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit); parsed.amount,
);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(
parsed.unit,
);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -63,14 +73,17 @@ export default function TrashRetention() {
setSaving(true); setSaving(true);
try { try {
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days }); const updatedWorkspace = await updateWorkspace({
trashRetentionDays: days,
});
setWorkspace(updatedWorkspace); setWorkspace(updatedWorkspace);
notifications.show({ notifications.show({
message: t("Trash retention updated"), message: t("Trash retention updated"),
}); });
} catch (err: any) { } catch (err: any) {
notifications.show({ notifications.show({
message: err?.response?.data?.message || t("Failed to update trash retention"), message:
err?.response?.data?.message || t("Failed to update trash retention"),
color: "red", color: "red",
}); });
const { amount, unit } = daysToRetention(currentDays); const { amount, unit } = daysToRetention(currentDays);
@@ -81,10 +94,11 @@ export default function TrashRetention() {
} }
}; };
const isDirty = retentionToDays( const isDirty =
typeof retentionAmount === "number" ? retentionAmount : 1, retentionToDays(
retentionUnit, typeof retentionAmount === "number" ? retentionAmount : 1,
) !== currentDays; retentionUnit,
) !== currentDays;
return ( return (
<div> <div>
@@ -93,10 +107,7 @@ export default function TrashRetention() {
{t("Pages in trash will be permanently deleted after this period.")} {t("Pages in trash will be permanently deleted after this period.")}
</Text> </Text>
<Tooltip <Tooltip label={upgradeLabel} disabled={hasAccess}>
label={t("Requires an enterprise license")}
disabled={hasAccess}
>
<Group gap="xs" wrap="nowrap" maw={320}> <Group gap="xs" wrap="nowrap" maw={320}>
<NumberInput <NumberInput
value={retentionAmount} value={retentionAmount}
@@ -12,14 +12,13 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx"; import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx"; import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx"; import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx"; import { Feature } from "@/ee/features";
export default function Security() { export default function Security() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const hasEnterpriseAccess = useEnterpriseAccess(); const hasSecurityAccess = useHasFeature(Feature.SECURITY_SETTINGS);
const isCloudEE = useIsCloudEE();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
@@ -36,7 +35,7 @@ export default function Security() {
<Divider my="lg" /> <Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && ( {(!isCloud() || hasSecurityAccess) && (
<> <>
<DisablePublicSharing /> <DisablePublicSharing />
<Divider my="lg" /> <Divider my="lg" />
@@ -54,21 +53,21 @@ export default function Security() {
Single sign-on (SSO) Single sign-on (SSO)
</Title> </Title>
{hasEnterpriseAccess && ( {hasSecurityAccess && (
<> <>
<EnforceSso /> <EnforceSso />
<Divider my="lg" /> <Divider my="lg" />
</> </>
)} )}
{isCloudEE && ( {hasSecurityAccess && (
<> <>
<AllowedDomains /> <AllowedDomains />
<Divider my="lg" /> <Divider my="lg" />
</> </>
)} )}
{hasEnterpriseAccess && ( {hasSecurityAccess && (
<> <>
<CreateSsoProvider /> <CreateSsoProvider />
<Divider size={0} my="lg" /> <Divider size={0} my="lg" />
@@ -7,7 +7,8 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions"; import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu"; import CommentMenu from "@/features/comment/components/comment-menu";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import ResolveComment from "@/ee/comment/components/resolve-comment"; import ResolveComment from "@/ee/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { import {
@@ -44,7 +45,7 @@ function CommentListItem({
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation(); const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const isCloudEE = useIsCloudEE(); const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const createdAtAgo = useTimeAgo(comment.createdAt); const createdAtAgo = useTimeAgo(comment.createdAt);
useEffect(() => { useEffect(() => {
@@ -81,7 +82,7 @@ function CommentListItem({
} }
async function handleResolveComment() { async function handleResolveComment() {
if (!isCloudEE) return; if (!canResolve) return;
try { try {
const isResolved = comment.resolvedAt != null; const isResolved = comment.resolvedAt != null;
@@ -137,7 +138,7 @@ function CommentListItem({
</Text> </Text>
<div style={{ visibility: hovered ? "visible" : "hidden" }}> <div style={{ visibility: hovered ? "visible" : "hidden" }}>
{!comment.parentCommentId && canComment && isCloudEE && ( {!comment.parentCommentId && canComment && canResolve && (
<ResolveComment <ResolveComment
editor={editor} editor={editor}
commentId={comment.id} commentId={comment.id}
@@ -1,8 +1,16 @@
import { ActionIcon, Menu, Tooltip } from "@mantine/core"; import { ActionIcon, Menu, Tooltip } from "@mantine/core";
import { IconDots, IconEdit, IconTrash, IconCircleCheck, IconCircleCheckFilled } from "@tabler/icons-react"; import {
IconDots,
IconEdit,
IconTrash,
IconCircleCheck,
IconCircleCheckFilled,
} from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type CommentMenuProps = { type CommentMenuProps = {
onEditComment: () => void; onEditComment: () => void;
@@ -13,16 +21,17 @@ type CommentMenuProps = {
isParentComment?: boolean; isParentComment?: boolean;
}; };
function CommentMenu({ function CommentMenu({
onEditComment, onEditComment,
onDeleteComment, onDeleteComment,
onResolveComment, onResolveComment,
canEdit = true, canEdit = true,
isResolved = false, isResolved = false,
isParentComment = false isParentComment = false,
}: CommentMenuProps) { }: CommentMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCloudEE = useIsCloudEE(); const canResolve = useHasFeature(Feature.COMMENT_RESOLUTION);
const upgradeLabel = useUpgradeLabel();
//@ts-ignore //@ts-ignore
const openDeleteModal = () => const openDeleteModal = () =>
@@ -44,33 +53,34 @@ function CommentMenu({
<Menu.Dropdown> <Menu.Dropdown>
{canEdit && ( {canEdit && (
<Menu.Item onClick={onEditComment} leftSection={<IconEdit size={14} />}> <Menu.Item
onClick={onEditComment}
leftSection={<IconEdit size={14} />}
>
{t("Edit comment")} {t("Edit comment")}
</Menu.Item> </Menu.Item>
)} )}
{isParentComment && ( {isParentComment &&
isCloudEE ? ( (canResolve ? (
<Menu.Item <Menu.Item
onClick={onResolveComment} onClick={onResolveComment}
leftSection={ leftSection={
isResolved ? isResolved ? (
<IconCircleCheckFilled size={14} /> : <IconCircleCheckFilled size={14} />
) : (
<IconCircleCheck size={14} /> <IconCircleCheck size={14} />
)
} }
> >
{isResolved ? t("Re-open comment") : t("Resolve comment")} {isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item> </Menu.Item>
) : ( ) : (
<Tooltip label={t("Available in enterprise edition")} position="left"> <Tooltip label={upgradeLabel} position="left">
<Menu.Item <Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
disabled
leftSection={<IconCircleCheck size={14} />}
>
{t("Resolve comment")} {t("Resolve comment")}
</Menu.Item> </Menu.Item>
</Tooltip> </Tooltip>
) ))}
)}
<Menu.Item <Menu.Item
leftSection={<IconTrash size={14} />} leftSection={<IconTrash size={14} />}
onClick={openDeleteModal} onClick={openDeleteModal}
@@ -28,9 +28,11 @@ import { IPage } from "@/features/page/types/page.types.ts";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx"; import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts"; import { getFileImportSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib"; import { formatBytes } from "@/lib";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts"; import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
@@ -82,7 +84,6 @@ interface ImportFormatSelection {
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) { function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const { t } = useTranslation(); const { t } = useTranslation();
const [treeData, setTreeData] = useAtom(treeDataAtom); const [treeData, setTreeData] = useAtom(treeDataAtom);
const [workspace] = useAtom(workspaceAtom);
const [fileTaskId, setFileTaskId] = useState<string | null>(null); const [fileTaskId, setFileTaskId] = useState<string | null>(null);
const emit = useQueryEmit(); const emit = useQueryEmit();
@@ -93,8 +94,9 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const confluenceFileRef = useRef<() => void>(null); const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null); const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
const canUseDocx = isCloud() || workspace?.hasLicenseKey; const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
const upgradeLabel = useUpgradeLabel();
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) { if (!selectedFile) {
@@ -360,7 +362,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseDocx} disabled={canUseDocx}
> >
<Button <Button
@@ -399,7 +401,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
> >
{(props) => ( {(props) => (
<Tooltip <Tooltip
label={t("Available in enterprise edition")} label={upgradeLabel}
disabled={canUseConfluence} disabled={canUseConfluence}
> >
<Button <Button
@@ -22,9 +22,9 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
@@ -42,7 +42,7 @@ export function SearchSpotlightFilters({
isAiMode = false, isAiMode = false,
}: SearchSpotlightFiltersProps) { }: SearchSpotlightFiltersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>( const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(
spaceId || null, spaceId || null,
); );
@@ -87,7 +87,7 @@ export function SearchSpotlightFilters({
{ {
value: "attachment", value: "attachment",
label: t("Attachments"), label: t("Attachments"),
disabled: !isCloud() && !hasLicenseKey, disabled: !hasAttachmentIndexing,
}, },
]; ];
@@ -11,15 +11,16 @@ import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts"; import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx"; import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx"; import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { isCloud } from "@/lib/config.ts"; import { Feature } from "@/ee/features";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
} }
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [debouncedSearchQuery] = useDebouncedValue(query, 300); const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const [filters, setFilters] = useState<{ const [filters, setFilters] = useState<{
@@ -84,7 +85,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
filters.contentType === "attachment" && (hasLicenseKey || isCloud()); filters.contentType === "attachment" && hasAttachmentIndexing;
const resultItems = (searchResults || []).map((result) => ( const resultItems = (searchResults || []).map((result) => (
<SearchResultItem <SearchResultItem
@@ -134,7 +135,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
} }
}} }}
/> />
{isAiMode && hasLicenseKey && ( {isAiMode && hasAiFeature && (
<Button <Button
size="xs" size="xs"
leftSection={<IconSparkles size={16} />} leftSection={<IconSparkles size={16} />}
@@ -8,8 +8,8 @@ import {
IPageSearch, IPageSearch,
IPageSearchParams, IPageSearchParams,
} from "@/features/search/types/search.types"; } from "@/features/search/types/search.types";
import { useLicense } from "@/ee/hooks/use-license"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { isCloud } from "@/lib/config"; import { Feature } from "@/ee/features";
export type UnifiedSearchResult = IPageSearch | IAttachmentSearch; export type UnifiedSearchResult = IPageSearch | IAttachmentSearch;
@@ -21,10 +21,10 @@ export function useUnifiedSearch(
params: UseUnifiedSearchParams, params: UseUnifiedSearchParams,
enabled: boolean = true, enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> { ): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
const isAttachmentSearch = const isAttachmentSearch =
params.contentType === "attachment" && (isCloud() || hasLicenseKey); params.contentType === "attachment" && hasAttachmentIndexing;
const searchType = isAttachmentSearch ? "attachment" : "page"; const searchType = isAttachmentSearch ? "attachment" : "page";
return useQuery({ return useQuery({
@@ -180,7 +180,7 @@ export default function ShareShell({
<AppShell.Main> <AppShell.Main>
{children} {children}
{data && shareId && !data.hasLicenseKey && <ShareBranding />} {data && shareId && !(data.features?.length > 0) && <ShareBranding />}
</AppShell.Main> </AppShell.Main>
<AppShell.Aside <AppShell.Aside
@@ -41,7 +41,7 @@ export interface ISharedPage extends IShare {
level: number; level: number;
sharedPage: { id: string; slugId: string; title: string; icon: string }; sharedPage: { id: string; slugId: string; title: string; icon: string };
}; };
hasLicenseKey: boolean; features?: string[];
} }
export interface IShareForPage extends IShare { export interface IShareForPage extends IShare {
@@ -71,5 +71,5 @@ export interface IShareInfoInput {
export interface ISharedPageTree { export interface ISharedPageTree {
share: IShare; share: IShare;
pageTree: Partial<IPage[]>; pageTree: Partial<IPage[]>;
hasLicenseKey: boolean; features?: string[];
} }
@@ -19,7 +19,8 @@ import {
ResponsiveSettingsRow, ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx"; } from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx"; import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -28,7 +29,7 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) { export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId); const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const hasEnterpriseAccess = useEnterpriseAccess(); const hasEnterpriseAccess = useHasFeature(Feature.SECURITY_SETTINGS);
const showSharingToggle = !readOnly && hasEnterpriseAccess; const showSharingToggle = !readOnly && hasEnterpriseAccess;
const [exportOpened, { open: openExportModal, close: closeExportModal }] = const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false); useDisclosure(false);
@@ -21,6 +21,7 @@ export interface IWorkspace {
memberCount?: number; memberCount?: number;
plan?: string; plan?: string;
hasLicenseKey?: boolean; hasLicenseKey?: boolean;
features?: string[];
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean; aiSearch?: boolean;
generativeAi?: boolean; generativeAi?: boolean;
@@ -84,7 +85,7 @@ export interface IPublicWorkspace {
hostname: string; hostname: string;
enforceSso: boolean; enforceSso: boolean;
authProviders: IAuthProvider[]; authProviders: IAuthProvider[];
hasLicenseKey?: boolean; features?: string[];
} }
export interface IVersion { export interface IVersion {
@@ -1,7 +0,0 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};
+1 -1
View File
@@ -68,7 +68,7 @@ export default function SharedPage() {
/> />
</Container> </Container>
{data && !shareId && !data.hasLicenseKey && <ShareBranding />} {data && !shareId && !(data.features?.length > 0) && <ShareBranding />}
</div> </div>
); );
} }
-9
View File
@@ -91,15 +91,6 @@ export function extractBearerTokenFromHeader(
return type === 'Bearer' ? token : undefined; return type === 'Bearer' ? token : undefined;
} }
export function hasLicenseOrEE(opts: {
licenseKey: string;
plan: string;
isCloud: boolean;
}): boolean {
const { licenseKey, plan, isCloud } = opts;
return Boolean(licenseKey) || (isCloud && plan === 'business');
}
/** /**
* Normalizes a database URL for postgres.js compatibility. * Normalizes a database URL for postgres.js compatibility.
* - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values * - Removes `sslmode=no-verify` (not supported by postgres.js), keeps other sslmode values
+10 -13
View File
@@ -28,8 +28,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { LicenseCheckService } from '../../integrations/environment/license-check.service';
import { hasLicenseOrEE } from '../../common/helpers';
import { AuditEvent, AuditResource } from '../../common/events/audit-events'; import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import { import {
AUDIT_SERVICE, AUDIT_SERVICE,
@@ -45,7 +44,7 @@ export class ShareController {
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo, private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService, private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService, private readonly licenseCheckService: LicenseCheckService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -81,11 +80,10 @@ export class ShareController {
return { return {
...shareData, ...shareData,
hasLicenseKey: hasLicenseOrEE({ features: this.licenseCheckService.resolveFeatures(
licenseKey: workspace.licenseKey, workspace.licenseKey,
isCloud: this.environmentService.isCloud(), workspace.plan,
plan: workspace.plan, ),
}),
}; };
} }
@@ -259,11 +257,10 @@ export class ShareController {
return { return {
...treeData, ...treeData,
hasLicenseKey: hasLicenseOrEE({ features: this.licenseCheckService.resolveFeatures(
licenseKey: workspace.licenseKey, workspace.licenseKey,
isCloud: this.environmentService.isCloud(), workspace.plan,
plan: workspace.plan, ),
}),
}; };
} }
} }
@@ -139,10 +139,10 @@ export class SpaceService {
}); });
if ( if (
!this.licenseCheckService.isValidEELicense(workspace.licenseKey) !this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings')
) { ) {
throw new ForbiddenException( throw new ForbiddenException(
'This feature requires a valid enterprise license', 'This feature requires a valid license',
); );
} }
} }
@@ -13,6 +13,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('users') @Controller('users')
@@ -20,6 +21,7 @@ export class UserController {
constructor( constructor(
private readonly userService: UserService, private readonly userService: UserService,
private readonly workspaceRepo: WorkspaceRepo, private readonly workspaceRepo: WorkspaceRepo,
private readonly licenseCheckService: LicenseCheckService,
) {} ) {}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -34,10 +36,16 @@ export class UserController {
const { licenseKey, ...rest } = workspace; const { licenseKey, ...rest } = workspace;
const features = this.licenseCheckService.resolveFeatures(
licenseKey,
rest.plan,
);
const workspaceInfo = { const workspaceInfo = {
...rest, ...rest,
memberCount, memberCount,
hasLicenseKey: Boolean(licenseKey), hasLicenseKey: Boolean(licenseKey),
features,
}; };
return { user: authUser, workspace: workspaceInfo }; return { user: authUser, workspace: workspaceInfo };
@@ -85,7 +85,7 @@ export class WorkspaceService {
async getWorkspacePublicData(workspaceId: string) { async getWorkspacePublicData(workspaceId: string) {
const workspace = await this.db const workspace = await this.db
.selectFrom('workspaces') .selectFrom('workspaces')
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey']) .select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey', 'plan'])
.select((eb) => .select((eb) =>
jsonArrayFrom( jsonArrayFrom(
eb eb
@@ -111,6 +111,7 @@ export class WorkspaceService {
return { return {
...rest, ...rest,
hasLicenseKey: Boolean(licenseKey), hasLicenseKey: Boolean(licenseKey),
features: this.licenseCheckService.resolveFeatures(licenseKey, rest.plan),
}; };
} }
@@ -336,9 +337,9 @@ export class WorkspaceService {
.where('id', '=', workspaceId) .where('id', '=', workspaceId)
.executeTakeFirst(); .executeTakeFirst();
if (!this.licenseCheckService.isValidEELicense(ws.licenseKey)) { if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings')) {
throw new ForbiddenException( throw new ForbiddenException(
'This feature requires a valid enterprise license', 'This feature requires a valid license',
); );
} }
@@ -506,6 +507,7 @@ export class WorkspaceService {
return { return {
...rest, ...rest,
hasLicenseKey: Boolean(licenseKey), hasLicenseKey: Boolean(licenseKey),
features: this.licenseCheckService.resolveFeatures(licenseKey, rest.plan),
}; };
} }
@@ -25,4 +25,48 @@ export class LicenseCheckService {
return false; return false;
} }
} }
hasFeature(licenseKey: string, feature: string): boolean {
if (this.environmentService.isCloud()) {
return true;
}
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);
}
} }