diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 2d9675b2..f35ec179 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -444,7 +444,6 @@ "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", @@ -626,7 +625,9 @@ "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", - "Enterprise feature": "Enterprise feature", + "Upgrade your plan": "Upgrade your plan", + "Available with a paid license": "Available with a paid license", + "Upgrade your license tier.": "Upgrade your license tier.", "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", @@ -634,17 +635,15 @@ "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", - "View the": "View the", - "for usage details.": "for usage details.", - "for setup instructions.": "for setup instructions.", - "API documentation": "API documentation", + "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Manage API keys for all users in the workspace. View the API documentation for usage details.", + "View the API documentation for usage details.": "View the API documentation for usage details.", + "View the MCP documentation.": "View the MCP documentation.", "Sources": "Sources", "AI Answers not available for attachments": "AI Answers not available for attachments", "No answer available": "No answer available", @@ -659,12 +658,12 @@ "Mark all as read": "Mark all as read", "Mark as read": "Mark as read", "More options": "More options", - "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", + "{{name}} mentioned you in a comment": "{{name}} mentioned you in a comment", + "{{name}} commented on a page": "{{name}} commented on a page", + "{{name}} resolved a comment": "{{name}} resolved a comment", + "{{name}} mentioned you on a page": "{{name}} mentioned you on a page", + "{{name}} gave you edit access to a page": "{{name}} gave you edit access to a page", + "{{name}} gave you view access to a page": "{{name}} gave you view access to a page", "Today": "Today", "Yesterday": "Yesterday", "This week": "This week", diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 6ac3587f..4b5c79bf 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -21,7 +21,9 @@ import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; import useUserRole from "@/hooks/use-user-role.tsx"; import { useAtom } from "jotai"; -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { prefetchApiKeyManagement, 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 { useSettingsNavigation } from "@/hooks/use-settings-navigation"; -interface DataItem { +type DataItem = { label: string; icon: React.ElementType; path: string; - isCloud?: boolean; - isEnterprise?: boolean; - isAdmin?: boolean; - isOwner?: boolean; - isSelfhosted?: boolean; - showDisabledInNonEE?: boolean; -} + feature?: string; + role?: "admin" | "owner"; + env?: "cloud" | "selfhosted"; +}; -interface DataGroup { +type DataGroup = { heading: string; items: DataItem[]; -} +}; const groupedData: DataGroup[] = [ { @@ -70,9 +69,7 @@ const groupedData: DataGroup[] = [ label: "API keys", icon: IconKey, path: "/settings/account/api-keys", - isCloud: true, - isEnterprise: true, - showDisabledInNonEE: true, + feature: Feature.API_KEYS, }, ], }, @@ -80,26 +77,20 @@ 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", - isCloud: true, - isAdmin: true, + role: "admin", + env: "cloud", }, { label: "Security & SSO", icon: IconLock, path: "/settings/security", - isCloud: true, - isEnterprise: true, - isAdmin: true, - showDisabledInNonEE: true, + feature: Feature.SECURITY_SETTINGS, + role: "admin", }, { label: "Groups", icon: IconUsersGroup, path: "/settings/groups" }, { label: "Spaces", icon: IconSpaces, path: "/settings/spaces" }, @@ -108,25 +99,22 @@ const groupedData: DataGroup[] = [ label: "API management", icon: IconKey, path: "/settings/api-keys", - isCloud: true, - isEnterprise: true, - isAdmin: true, - showDisabledInNonEE: true, + feature: Feature.API_KEYS, + role: "admin", }, { label: "AI settings", icon: IconSparkles, path: "/settings/ai", - isAdmin: true, + role: "admin", }, { label: "Audit log", icon: IconHistory, path: "/settings/audit", - isEnterprise: true, - isOwner: true, - isSelfhosted: true, - showDisabledInNonEE: true, + feature: Feature.AUDIT_LOGS, + role: "owner", + env: "selfhosted", }, ], }, @@ -148,7 +136,8 @@ export default function SettingsSidebar() { const [active, setActive] = useState(location.pathname); const { goBack } = useSettingsNavigation(); const { isAdmin, isOwner } = useUserRole(); - const [workspace] = useAtom(workspaceAtom); + const [entitlements] = useAtom(entitlementAtom); + const upgradeLabel = useUpgradeLabel(); const [mobileSidebarOpened] = useAtom(mobileSidebarAtom); const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom); @@ -156,43 +145,20 @@ export default function SettingsSidebar() { setActive(location.pathname); }, [location.pathname]); - const hasRoleAccess = (item: DataItem) => { - if (item.isOwner) return isOwner; - if (item.isAdmin) return isAdmin; + 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; 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.showDisabledInNonEE && item.isEnterprise) { - return !(isCloud() || workspace?.hasLicenseKey); - } - return false; + if (!item.feature) return false; + return !hasFeature(item.feature); }; const menuItems = groupedData.map((group) => { @@ -225,7 +191,7 @@ export default function SettingsSidebar() { prefetchHandler = prefetchBilling; break; case "License & Edition": - if (workspace?.hasLicenseKey) { + if (entitlements?.tier !== "free") { prefetchHandler = prefetchLicense; } break; @@ -280,7 +246,7 @@ export default function SettingsSidebar() { return ( diff --git a/apps/client/src/ee/ai/components/enable-ai-search.tsx b/apps/client/src/ee/ai/components/enable-ai-search.tsx index 91242804..3a2abd26 100644 --- a/apps/client/src/ee/ai/components/enable-ai-search.tsx +++ b/apps/client/src/ee/ai/components/enable-ai-search.tsx @@ -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 { 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 { isCloud } from "@/lib/config.ts"; -import useLicense from "@/ee/hooks/use-license.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; export default function EnableAiSearch() { const { t } = useTranslation(); @@ -37,9 +38,8 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); const [checked, setChecked] = useState(workspace?.settings?.ai?.search); - const { hasLicenseKey } = useLicense(); - - const hasAccess = isCloud() || (!isCloud() && hasLicenseKey); + const hasAccess = useHasFeature(Feature.AI); + const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; @@ -56,14 +56,16 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) { }; return ( - + + + ); } diff --git a/apps/client/src/ee/ai/components/enable-generative-ai.tsx b/apps/client/src/ee/ai/components/enable-generative-ai.tsx index 9e09f4f0..1db611ce 100644 --- a/apps/client/src/ee/ai/components/enable-generative-ai.tsx +++ b/apps/client/src/ee/ai/components/enable-generative-ai.tsx @@ -1,17 +1,20 @@ -import { Group, Text, Switch } from "@mantine/core"; +import { Group, Text, Switch, Tooltip } 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 { 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() { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); 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) => { const value = event.currentTarget.checked; @@ -38,11 +41,13 @@ export default function EnableGenerativeAi() { - + + + ); } diff --git a/apps/client/src/ee/ai/components/mcp-settings.tsx b/apps/client/src/ee/ai/components/mcp-settings.tsx index d39d285b..e7cc2234 100644 --- a/apps/client/src/ee/ai/components/mcp-settings.tsx +++ b/apps/client/src/ee/ai/components/mcp-settings.tsx @@ -13,10 +13,12 @@ import { 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 { Trans, useTranslation } from "react-i18next"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; 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 { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; import { CopyButton } from "@/components/common/copy-button.tsx"; @@ -25,7 +27,8 @@ export default function McpSettings() { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp); - const hasAccess = useIsCloudEE(); + const hasAccess = useHasFeature(Feature.MCP); + const upgradeLabel = useUpgradeLabel(); const mcpUrl = `${getAppUrl()}/mcp`; @@ -46,11 +49,7 @@ export default function McpSettings() { return ( {!hasAccess && ( - } - title={t("Enterprise feature")} - color="blue" - > + } title={upgradeLabel} color="blue"> {t( "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", )} @@ -64,23 +63,22 @@ export default function McpSettings() { {t( "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", )}{" "} - {t("View the")}{" "} - - {t("MCP documentation")} - - . + , + }} + /> - + + + {checked && ( @@ -89,11 +87,7 @@ export default function McpSettings() { {t("MCP Server URL")} - + {({ copied, copy }) => ( - search_pages, get_page, create_page, update_page - list_pages, list_child_pages, duplicate_page - copy_page_to_space, move_page, move_page_to_space - get_space, list_spaces, create_space, update_space - get_comments, create_comment, update_comment - search_attachments, list_workspace_members, get_current_user + + + search_pages, get_page, create_page, update_page + + + + + list_pages, list_child_pages, duplicate_page + + + + + copy_page_to_space, move_page, move_page_to_space + + + + + get_space, list_spaces, create_space, update_space + + + + + get_comments, create_comment, update_comment + + + + + search_attachments, list_workspace_members, get_current_user + + diff --git a/apps/client/src/ee/ai/pages/ai-settings.tsx b/apps/client/src/ee/ai/pages/ai-settings.tsx index 53fa9a87..c3f93810 100644 --- a/apps/client/src/ee/ai/pages/ai-settings.tsx +++ b/apps/client/src/ee/ai/pages/ai-settings.tsx @@ -9,14 +9,17 @@ 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 { 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 { useLocation, useNavigate } from "react-router-dom"; export default function AiSettings() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); - const hasAccess = useIsCloudEE(); + const hasAccess = useHasFeature(Feature.AI); + const upgradeLabel = useUpgradeLabel(); const location = useLocation(); const navigate = useNavigate(); @@ -55,7 +58,7 @@ export default function AiSettings() { {!hasAccess && ( } - title={t("Enterprise feature")} + title={upgradeLabel} color="blue" mb="lg" > diff --git a/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx b/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx index accbc545..356d3dcb 100644 --- a/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx +++ b/apps/client/src/ee/api-key/components/restrict-api-to-admins.tsx @@ -5,12 +5,14 @@ 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 useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; 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(); @@ -18,7 +20,8 @@ export default function RestrictApiToAdmins() { const [checked, setChecked] = useState( workspace?.settings?.api?.restrictToAdmins === true, ); - const hasAccess = useEnterpriseAccess(); + const hasAccess = useHasFeature(Feature.API_KEYS); + const upgradeLabel = useUpgradeLabel(); const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; @@ -51,7 +54,7 @@ export default function RestrictApiToAdmins() { diff --git a/apps/client/src/ee/api-key/pages/user-api-keys.tsx b/apps/client/src/ee/api-key/pages/user-api-keys.tsx index e4951805..c305f4af 100644 --- a/apps/client/src/ee/api-key/pages/user-api-keys.tsx +++ b/apps/client/src/ee/api-key/pages/user-api-keys.tsx @@ -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 { useTranslation } from "react-i18next"; +import { Trans, 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,11 +58,12 @@ export default function UserApiKeys() { - {t("View the")}{" "} - - {t("API documentation")} - {" "} - {t("for usage details.")} + , + }} + /> {mcpEnabled && canCreate && ( diff --git a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx index 155f7651..8476f445 100644 --- a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx +++ b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx @@ -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 { useTranslation } from "react-i18next"; +import { Trans, 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() { - {t("Manage API keys for all users in the workspace.")}{" "} - {t("View the")}{" "} - - {t("API documentation")} - {" "} - {t("for usage details.")} + , + }} + /> diff --git a/apps/client/src/ee/components/sso-login.tsx b/apps/client/src/ee/components/sso-login.tsx index 8c96d9c5..ff739dd3 100644 --- a/apps/client/src/ee/components/sso-login.tsx +++ b/apps/client/src/ee/components/sso-login.tsx @@ -6,7 +6,6 @@ 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() { @@ -57,7 +56,7 @@ export default function SsoLogin() { /> )} - {(isCloud() || data.hasLicenseKey) && ( + {data.authProviders.length > 0 && ( <> {data.authProviders.map((provider) => ( diff --git a/apps/client/src/ee/entitlement/entitlement-atom.ts b/apps/client/src/ee/entitlement/entitlement-atom.ts new file mode 100644 index 00000000..e6d38512 --- /dev/null +++ b/apps/client/src/ee/entitlement/entitlement-atom.ts @@ -0,0 +1,7 @@ +import { atomWithStorage } from "jotai/utils"; +import type { Entitlements } from "./entitlement.types"; + +export const entitlementAtom = atomWithStorage( + "entitlements", + null, +); diff --git a/apps/client/src/ee/entitlement/entitlement-service.ts b/apps/client/src/ee/entitlement/entitlement-service.ts new file mode 100644 index 00000000..0bc0c9ea --- /dev/null +++ b/apps/client/src/ee/entitlement/entitlement-service.ts @@ -0,0 +1,7 @@ +import api from "@/lib/api-client"; +import { Entitlements } from "./entitlement.types"; + +export async function getEntitlements(): Promise { + const req = await api.post("/workspace/entitlements"); + return req.data as Entitlements; +} diff --git a/apps/client/src/ee/entitlement/entitlement.types.ts b/apps/client/src/ee/entitlement/entitlement.types.ts new file mode 100644 index 00000000..2ec3ab3b --- /dev/null +++ b/apps/client/src/ee/entitlement/entitlement.types.ts @@ -0,0 +1,7 @@ +export type Tier = "free" | "standard" | "business" | "enterprise"; + +export type Entitlements = { + cloud: boolean; + tier: Tier; + features: string[]; +}; diff --git a/apps/client/src/ee/entitlement/use-entitlements.ts b/apps/client/src/ee/entitlement/use-entitlements.ts new file mode 100644 index 00000000..d4bfeaf8 --- /dev/null +++ b/apps/client/src/ee/entitlement/use-entitlements.ts @@ -0,0 +1,11 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { getEntitlements } from "./entitlement-service"; +import { Entitlements } from "./entitlement.types"; + +export function useEntitlements(): UseQueryResult { + return useQuery({ + queryKey: ["entitlements"], + queryFn: getEntitlements, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts new file mode 100644 index 00000000..70438cba --- /dev/null +++ b/apps/client/src/ee/features.ts @@ -0,0 +1,19 @@ +export const Feature = { + SSO_CUSTOM: 'sso:custom', + SSO_GOOGLE: 'sso:google', + MFA: 'mfa', + API_KEYS: 'api:keys', + COMMENT_RESOLUTION: 'comment:resolution', + PAGE_PERMISSIONS: 'page:permissions', + AI: 'ai', + CONFLUENCE_IMPORT: 'import:confluence', + DOCX_IMPORT: 'import:docx', + ATTACHMENT_INDEXING: 'attachment:indexing', + SECURITY_SETTINGS: 'security:settings', + MCP: 'mcp', + SCIM: 'scim', + PAGE_VERIFICATION: 'page:verification', + AUDIT_LOGS: 'audit:logs', + RETENTION: 'retention', + SHARING_CONTROLS: 'sharing:controls', +} as const; diff --git a/apps/client/src/ee/hooks/use-enterprise-access.tsx b/apps/client/src/ee/hooks/use-enterprise-access.tsx deleted file mode 100644 index b7746d6f..00000000 --- a/apps/client/src/ee/hooks/use-enterprise-access.tsx +++ /dev/null @@ -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; diff --git a/apps/client/src/ee/hooks/use-feature.ts b/apps/client/src/ee/hooks/use-feature.ts new file mode 100644 index 00000000..5521477c --- /dev/null +++ b/apps/client/src/ee/hooks/use-feature.ts @@ -0,0 +1,7 @@ +import { useAtom } from "jotai"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; + +export const useHasFeature = (feature: string): boolean => { + const [entitlements] = useAtom(entitlementAtom); + return entitlements?.features?.includes(feature) ?? false; +}; diff --git a/apps/client/src/ee/hooks/use-license.tsx b/apps/client/src/ee/hooks/use-license.tsx deleted file mode 100644 index e3f72d82..00000000 --- a/apps/client/src/ee/hooks/use-license.tsx +++ /dev/null @@ -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; diff --git a/apps/client/src/ee/hooks/use-upgrade-label.ts b/apps/client/src/ee/hooks/use-upgrade-label.ts new file mode 100644 index 00000000..22253c7b --- /dev/null +++ b/apps/client/src/ee/hooks/use-upgrade-label.ts @@ -0,0 +1,16 @@ +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; +import { isCloud } from "@/lib/config"; + +export function useUpgradeLabel(): string { + const { t } = useTranslation(); + const [entitlements] = useAtom(entitlementAtom); + + if (!isCloud()) { + return entitlements != null && entitlements.tier !== "free" + ? t("Upgrade your license tier.") + : t("Available with a paid license"); + } + return t("Upgrade your plan"); +} diff --git a/apps/client/src/ee/licence/components/activate-license-modal.tsx b/apps/client/src/ee/licence/components/activate-license-modal.tsx index d9f68b22..28b3d0d6 100644 --- a/apps/client/src/ee/licence/components/activate-license-modal.tsx +++ b/apps/client/src/ee/licence/components/activate-license-modal.tsx @@ -7,21 +7,22 @@ import { useTranslation } from "react-i18next"; import { useActivateMutation } from "@/ee/licence/queries/license-query.ts"; import { useDisclosure } from "@mantine/hooks"; import { useAtom } from "jotai"; -import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; import RemoveLicense from "@/ee/licence/components/remove-license.tsx"; export default function ActivateLicense() { const { t } = useTranslation(); const [opened, { open, close }] = useDisclosure(false); - const [workspace] = useAtom(workspaceAtom); + const [entitlements] = useAtom(entitlementAtom); + const hasLicense = entitlements != null && entitlements.tier !== "free"; return ( - {workspace?.hasLicenseKey && } + {hasLicense && } Edition - Enterprise {license.trial && Trial} + {license.licenseType === "business" ? "Business" : "Enterprise"}{" "} + {license.trial && Trial} diff --git a/apps/client/src/ee/licence/pages/license.tsx b/apps/client/src/ee/licence/pages/license.tsx index f8d685c1..0aa9d2f5 100644 --- a/apps/client/src/ee/licence/pages/license.tsx +++ b/apps/client/src/ee/licence/pages/license.tsx @@ -8,10 +8,11 @@ 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 { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { entitlementAtom } from "@/ee/entitlement/entitlement-atom"; export default function License() { - const [workspace] = useAtom(workspaceAtom); + const [entitlements] = useAtom(entitlementAtom); + const hasLicense = entitlements != null && entitlements.tier !== "free"; const { isAdmin } = useUserRole(); if (!isAdmin) { @@ -29,7 +30,7 @@ export default function License() { - {workspace?.hasLicenseKey ? : } + {hasLicense ? : } ); } diff --git a/apps/client/src/ee/licence/queries/license-query.ts b/apps/client/src/ee/licence/queries/license-query.ts index 90e74304..07f1d7e8 100644 --- a/apps/client/src/ee/licence/queries/license-query.ts +++ b/apps/client/src/ee/licence/queries/license-query.ts @@ -31,6 +31,7 @@ export function useActivateMutation() { queryKey: ["license"], }); queryClient.refetchQueries({ queryKey: ["currentUser"] }); + queryClient.refetchQueries({ queryKey: ["entitlements"] }); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; @@ -47,6 +48,7 @@ export function useRemoveLicenseMutation() { onSuccess: () => { queryClient.refetchQueries({ queryKey: ["license"] }); queryClient.refetchQueries({ queryKey: ["currentUser"] }); + queryClient.refetchQueries({ queryKey: ["entitlements"] }); }, }); } diff --git a/apps/client/src/ee/licence/types/license.types.ts b/apps/client/src/ee/licence/types/license.types.ts index e0493a64..ec3c9a18 100644 --- a/apps/client/src/ee/licence/types/license.types.ts +++ b/apps/client/src/ee/licence/types/license.types.ts @@ -1,7 +1,10 @@ +export type LicenseType = 'business' | 'enterprise'; + export interface ILicenseInfo { id: string; customerName: string; seatCount: number; + licenseType: LicenseType; issuedAt: Date; expiresAt: Date; trial: boolean; diff --git a/apps/client/src/ee/mfa/components/mfa-settings.tsx b/apps/client/src/ee/mfa/components/mfa-settings.tsx index 73d9247d..620d67ac 100644 --- a/apps/client/src/ee/mfa/components/mfa-settings.tsx +++ b/apps/client/src/ee/mfa/components/mfa-settings.tsx @@ -7,8 +7,9 @@ import { getMfaStatus } from "@/ee/mfa"; import { MfaSetupModal } from "@/ee/mfa"; import { MfaDisableModal } from "@/ee/mfa"; import { MfaBackupCodesModal } from "@/ee/mfa"; -import { isCloud } from "@/lib/config.ts"; -import useLicense from "@/ee/hooks/use-license.tsx"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row"; export function MfaSettings() { @@ -17,7 +18,8 @@ export function MfaSettings() { const [setupModalOpen, setSetupModalOpen] = useState(false); const [disableModalOpen, setDisableModalOpen] = useState(false); const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false); - const { hasLicenseKey } = useLicense(); + const canUseMfa = useHasFeature(Feature.MFA); + const upgradeLabel = useUpgradeLabel(); const { data: mfaStatus, isLoading } = useQuery({ queryKey: ["mfa-status"], @@ -28,8 +30,6 @@ 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() { {!isMfaEnabled ? (