This commit is contained in:
Philipinho
2026-03-09 00:51:14 +00:00
parent 78c3839ae7
commit ff01355ec3
19 changed files with 115 additions and 50 deletions
@@ -623,7 +623,7 @@
"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 features require a paid plan. Visit docmost.com for more information.": "AI features require a paid plan. Visit docmost.com for more information.",
"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",
"MCP": "MCP",
@@ -21,7 +21,7 @@ import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import {
@@ -136,7 +136,7 @@ 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);
@@ -146,7 +146,7 @@ export default function SettingsSidebar() {
}, [location.pathname]);
const hasFeature = (f: string) =>
workspace?.features?.includes(f) ?? false;
entitlements?.features?.includes(f) ?? false;
const canShowItem = (item: DataItem) => {
if (item.env === "cloud" && !isCloud()) return false;
@@ -191,7 +191,7 @@ export default function SettingsSidebar() {
prefetchHandler = prefetchBilling;
break;
case "License & Edition":
if (workspace?.hasLicenseKey) {
if (entitlements?.tier !== "free") {
prefetchHandler = prefetchLicense;
}
break;
+1 -1
View File
@@ -63,7 +63,7 @@ export default function AiSettings() {
mb="lg"
>
{t(
"AI features require a paid plan. Visit docmost.com for more information.",
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
+1 -1
View File
@@ -56,7 +56,7 @@ export default function SsoLogin() {
/>
)}
{(data.features?.length > 0) && (
{data.authProviders.length > 0 && (
<>
<Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => (
@@ -0,0 +1,5 @@
import { atom } from "jotai";
import type { Entitlements } from "./entitlement.types";
const initialValue: Entitlements | null = null;
export const entitlementAtom = atom(initialValue);
@@ -0,0 +1,7 @@
import api from "@/lib/api-client";
import { Entitlements } from "./entitlement.types";
export async function getEntitlements(): Promise<Entitlements> {
const req = await api.post<Entitlements>("/workspace/entitlements");
return req.data as Entitlements;
}
@@ -0,0 +1,7 @@
export type Tier = "free" | "standard" | "business" | "enterprise";
export type Entitlements = {
cloud: boolean;
tier: Tier;
features: string[];
};
@@ -0,0 +1,11 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getEntitlements } from "./entitlement-service";
import { Entitlements } from "./entitlement.types";
export function useEntitlements(): UseQueryResult<Entitlements> {
return useQuery({
queryKey: ["entitlements"],
queryFn: getEntitlements,
staleTime: 5 * 60 * 1000,
});
}
+3 -8
View File
@@ -1,12 +1,7 @@
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { entitlementAtom } from "@/ee/entitlement/entitlement-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;
const [entitlements] = useAtom(entitlementAtom);
return entitlements?.features?.includes(feature) ?? false;
};
@@ -1,14 +1,14 @@
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { isCloud } from "@/lib/config";
export function useUpgradeLabel(): string {
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const [entitlements] = useAtom(entitlementAtom);
if (!isCloud()) {
return workspace?.hasLicenseKey
return entitlements != null && entitlements.tier !== "free"
? t("Upgrade your license tier.")
: t("Available with a paid license");
}
@@ -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 (
<Group justify="flex-end" wrap="nowrap" mb="sm">
<Button onClick={open}>
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
{hasLicense ? t("Update license") : t("Add license")}
</Button>
{workspace?.hasLicenseKey && <RemoveLicense />}
{hasLicense && <RemoveLicense />}
<Modal
size="550"
@@ -59,7 +60,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset();
onClose();
onClose?.();
}
return (
+4 -3
View File
@@ -8,10 +8,11 @@ import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.
import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
import 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() {
<InstallationDetails />
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
{hasLicense ? <LicenseDetails /> : <OssDetails />}
</>
);
}
@@ -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"] });
},
});
}
@@ -1,4 +1,4 @@
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import React, { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user";
@@ -11,10 +11,14 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { useEntitlements } from "@/ee/entitlement/use-entitlements";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export function UserProvider({ children }: React.PropsWithChildren) {
const [, setCurrentUser] = useAtom(currentUserAtom);
const setEntitlements = useSetAtom(entitlementAtom);
const { data, isLoading, error, isError } = useCurrentUser();
const { data: entitlements } = useEntitlements();
const { i18n } = useTranslation();
const [, setSocket] = useAtom(socketAtom);
// fetch collab token on load
@@ -56,6 +60,12 @@ export function UserProvider({ children }: React.PropsWithChildren) {
}
}, [data, isLoading]);
useEffect(() => {
if (entitlements) {
setEntitlements(entitlements);
}
}, [entitlements]);
if (isLoading) return <></>;
if (isError && error?.["response"]?.status === 404) {
@@ -20,8 +20,6 @@ export interface IWorkspace {
emailDomains: string[];
memberCount?: number;
plan?: string;
hasLicenseKey?: boolean;
features?: string[];
enforceMfa?: boolean;
aiSearch?: boolean;
generativeAi?: boolean;
@@ -85,7 +83,6 @@ export interface IPublicWorkspace {
hostname: string;
enforceSso: boolean;
authProviders: IAuthProvider[];
features?: string[];
}
export interface IVersion {
@@ -13,7 +13,6 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { LicenseCheckService } from '../../integrations/environment/license-check.service';
@UseGuards(JwtAuthGuard)
@Controller('users')
@@ -21,7 +20,6 @@ export class UserController {
constructor(
private readonly userService: UserService,
private readonly workspaceRepo: WorkspaceRepo,
private readonly licenseCheckService: LicenseCheckService,
) {}
@HttpCode(HttpStatus.OK)
@@ -36,16 +34,9 @@ export class UserController {
const { licenseKey, ...rest } = workspace;
const features = this.licenseCheckService.resolveFeatures(
licenseKey,
rest.plan,
);
const workspaceInfo = {
...rest,
memberCount,
hasLicenseKey: Boolean(licenseKey),
features,
};
return { user: authUser, workspace: workspaceInfo };
@@ -32,8 +32,10 @@ import {
} from '../../casl/interfaces/workspace-ability.type';
import { FastifyReply } from 'fastify';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
import { CheckHostnameDto } from '../dto/check-hostname.dto';
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@UseGuards(JwtAuthGuard)
@Controller('workspace')
@@ -42,7 +44,9 @@ export class WorkspaceController {
private readonly workspaceService: WorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService,
private licenseCheckService: LicenseCheckService,
) {}
@Public()
@@ -58,6 +62,23 @@ export class WorkspaceController {
return this.workspaceService.getWorkspaceInfo(workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('entitlements')
async getEntitlements(@AuthWorkspace() workspace: Workspace) {
let { licenseKey } = workspace;
const { plan } = workspace;
if (!licenseKey) {
licenseKey = await this.workspaceRepo.findLicenseKeyById(workspace.id);
}
return {
cloud: this.environmentService.isCloud(),
tier: this.licenseCheckService.resolveTier(licenseKey, plan),
features: this.licenseCheckService.resolveFeatures(licenseKey, plan),
};
}
@HttpCode(HttpStatus.OK)
@Post('update')
async updateWorkspace(
@@ -106,13 +106,9 @@ export class WorkspaceService {
throw new NotFoundException('Workspace not found');
}
const { licenseKey, ...rest } = workspace;
const { licenseKey, plan, ...rest } = workspace;
return {
...rest,
hasLicenseKey: Boolean(licenseKey),
features: this.licenseCheckService.resolveFeatures(licenseKey, rest.plan),
};
return rest;
}
async create(
@@ -337,6 +333,10 @@ export class WorkspaceService {
.where('id', '=', workspaceId)
.executeTakeFirst();
if (!ws) {
throw new NotFoundException('Workspace not found');
}
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings')) {
throw new ForbiddenException(
'This feature requires a valid license',
@@ -504,11 +504,7 @@ export class WorkspaceService {
}
const { licenseKey, ...rest } = workspace;
return {
...rest,
hasLicenseKey: Boolean(licenseKey),
features: this.licenseCheckService.resolveFeatures(licenseKey, rest.plan),
};
return rest;
}
async getWorkspaceUsers(
@@ -69,4 +69,25 @@ export class LicenseCheckService {
return this.getFeatures(licenseKey);
}
resolveTier(licenseKey: string, plan: string): string {
if (this.environmentService.isCloud()) {
return plan ?? 'standard';
}
return this.getLicenseType(licenseKey) ?? 'free';
}
private getLicenseType(licenseKey: string): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const LicenseModule = require('../../ee/licence/license.service');
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
strict: false,
});
return licenseService.getLicenseType(licenseKey);
} catch {
return null;
}
}
}