mirror of
https://github.com/docmost/docmost.git
synced 2026-05-10 16:24:05 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b218d31ed | |||
| a27e683e06 | |||
| 37f349f040 | |||
| 7dcf5006d3 | |||
| 4f20b43222 | |||
| 06c81a4fed |
@@ -407,21 +407,6 @@
|
||||
"Share deleted successfully": "Share deleted successfully",
|
||||
"Share not found": "Share not found",
|
||||
"Failed to share page": "Failed to share page",
|
||||
"Disable public sharing": "Disable public sharing",
|
||||
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
|
||||
"Toggle public sharing": "Toggle 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",
|
||||
"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",
|
||||
"Enable public sharing": "Enable public sharing",
|
||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
|
||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
|
||||
"Are you sure you want to enable public sharing for this space?": "Are you sure you want to enable public sharing for this space?",
|
||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
|
||||
"Public sharing is disabled": "Public sharing is disabled",
|
||||
"Public sharing has been disabled at the workspace level.": "Public sharing has been disabled at the workspace level.",
|
||||
"Public sharing has been disabled for this space.": "Public sharing has been disabled for this space.",
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully",
|
||||
|
||||
@@ -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;
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
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 useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
||||
|
||||
export default function DisablePublicSharing() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Disable public sharing")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Prevent members from sharing pages publicly.")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<DisablePublicSharingToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function DisablePublicSharingToggle() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(
|
||||
workspace?.settings?.sharing?.disabled === true,
|
||||
);
|
||||
const hasAccess = useEnterpriseAccess();
|
||||
|
||||
const applyChange = async (value: boolean) => {
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({
|
||||
disablePublicSharing: value,
|
||||
});
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
|
||||
modals.openConfirmModal({
|
||||
title: value ? t("Disable public sharing") : t("Enable public sharing"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{value
|
||||
? t(
|
||||
"Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.",
|
||||
)
|
||||
: t(
|
||||
"Are you sure you want to enable public sharing? Members will be able to share pages publicly.",
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
confirmProps: value ? { color: "red" } : {},
|
||||
onConfirm: () => applyChange(value),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={t("Requires an enterprise license")}
|
||||
disabled={hasAccess}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle public sharing")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,18 +10,23 @@ export default function EnforceMfa() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<>
|
||||
<Title order={4} my="sm">
|
||||
MFA
|
||||
</Title>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Enforce two-factor authentication")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, all members must enable two-factor authentication to access the workspace.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<EnforceMfaToggle />
|
||||
</Group>
|
||||
<EnforceMfaToggle />
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
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 { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
|
||||
type SpacePublicSharingToggleProps = {
|
||||
space: ISpace;
|
||||
};
|
||||
|
||||
export default function SpacePublicSharingToggle({
|
||||
space,
|
||||
}: SpacePublicSharingToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.sharing?.disabled === true,
|
||||
);
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const applyChange = async (value: boolean) => {
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
disablePublicSharing: value,
|
||||
});
|
||||
setChecked(value);
|
||||
} catch {
|
||||
// error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
|
||||
modals.openConfirmModal({
|
||||
title: value ? t("Disable public sharing") : t("Enable public sharing"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{value
|
||||
? t(
|
||||
"Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.",
|
||||
)
|
||||
: t(
|
||||
"Are you sure you want to enable public sharing for this space?",
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Confirm"), cancel: t("Cancel") },
|
||||
confirmProps: value ? { color: "red" } : {},
|
||||
onConfirm: () => applyChange(value),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Disable public sharing")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{workspaceDisabled
|
||||
? t("Public sharing is disabled at the workspace level")
|
||||
: t("Prevent pages in this space from being shared publicly.")}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t("Public sharing is disabled at the workspace level")}
|
||||
disabled={!workspaceDisabled}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={workspaceDisabled}
|
||||
aria-label={t("Toggle space public sharing")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,15 @@ import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx"
|
||||
import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
|
||||
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLicense from "@/ee/hooks/use-license.tsx";
|
||||
import usePlan from "@/ee/hooks/use-plan.tsx";
|
||||
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
|
||||
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
|
||||
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
||||
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
|
||||
|
||||
export default function Security() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const hasEnterpriseAccess = useEnterpriseAccess();
|
||||
const isCloudEE = useIsCloudEE();
|
||||
const { hasLicenseKey } = useLicense();
|
||||
const { isBusiness } = usePlan();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
@@ -31,41 +30,26 @@ export default function Security() {
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("Security")} />
|
||||
|
||||
<EnforceMfa />
|
||||
<AllowedDomains />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
{(!isCloud() || hasEnterpriseAccess) && (
|
||||
<>
|
||||
<DisablePublicSharing />
|
||||
<Divider my="lg" />
|
||||
</>
|
||||
)}
|
||||
<EnforceMfa />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<Title order={4} my="lg">
|
||||
Single sign-on (SSO)
|
||||
</Title>
|
||||
|
||||
{hasEnterpriseAccess && (
|
||||
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
|
||||
<>
|
||||
<EnforceSso />
|
||||
<Divider my="lg" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCloudEE && (
|
||||
<>
|
||||
<AllowedDomains />
|
||||
<Divider my="lg" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasEnterpriseAccess && (
|
||||
<>
|
||||
<CreateSsoProvider />
|
||||
<Divider size={0} my="lg" />
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<SsoProviderList />
|
||||
</>
|
||||
|
||||
@@ -8,3 +8,5 @@ export const titleEditorAtom = atom<Editor | null>(null);
|
||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
export const yjsConnectionStatusAtom = atom<string>("");
|
||||
|
||||
export const showAiMenuAtom = atom(false);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.aiMenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
.menuItemSelected {
|
||||
background-color: var(--mantine-color-gray-1);
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-5);
|
||||
}
|
||||
}
|
||||
|
||||
.resultPreviewWrapper {
|
||||
*:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
*:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
|
||||
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconSend } from "@tabler/icons-react";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query";
|
||||
import { AiAction } from "@/ee/ai/types/ai.types";
|
||||
import { CommandItem, commandItems, CommandSet } from "./command-items";
|
||||
import { CommandSelector } from "./command-selector";
|
||||
import { ResultPreview } from "./result-preview";
|
||||
import classes from "./ai-menu.module.css";
|
||||
import { marked } from "marked";
|
||||
|
||||
interface EditorAiMenuProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
|
||||
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
|
||||
const isSmBreakpoint = useMediaQuery("(max-width: 48em)");
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [output, setOutput] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
|
||||
const [lastAction, setLastAction] = useState<CommandItem | null>(null);
|
||||
const [menuPlacement, setMenuPlacement] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
}>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const currentItems = useMemo(() => {
|
||||
return commandItems[activeCommandSet].filter((item) => {
|
||||
return item.name.toLowerCase().includes(prompt.toLowerCase());
|
||||
});
|
||||
}, [prompt, output, activeCommandSet]);
|
||||
const updateMenuPlacement = useCallback(() => {
|
||||
if (!editor || !showAiMenu) return;
|
||||
|
||||
const { view } = editor;
|
||||
const { to } = editor.state.selection;
|
||||
const editorRect = view.dom.getBoundingClientRect();
|
||||
const cursorCoords = view.coordsAtPos(to);
|
||||
const topOffset = 8;
|
||||
const editorPadding = isSmBreakpoint ? 16 : 48;
|
||||
|
||||
setMenuPlacement({
|
||||
top: cursorCoords.bottom + topOffset,
|
||||
left: editorRect.left + editorPadding,
|
||||
width: editorRect.width - editorPadding * 2,
|
||||
});
|
||||
}, [editor, showAiMenu, isSmBreakpoint]);
|
||||
const resetMenu = useCallback(() => {
|
||||
setPrompt("");
|
||||
setOutput("");
|
||||
setActiveCommandSet("main");
|
||||
setLastAction(null);
|
||||
aiGenerateStreamMutation.reset();
|
||||
}, [aiGenerateStreamMutation.reset]);
|
||||
const debouncedUpdateMenuPlacement = useDebouncedCallback(
|
||||
updateMenuPlacement,
|
||||
60,
|
||||
);
|
||||
const handleGenerate = useCallback(
|
||||
(item?: CommandItem) => {
|
||||
if (!editor || isLoading) return;
|
||||
|
||||
let command: CommandItem | null = item || null;
|
||||
|
||||
if (!command) {
|
||||
if (!prompt) return;
|
||||
|
||||
command = {
|
||||
id: "custom",
|
||||
name: "Custom",
|
||||
action: AiAction.CUSTOM,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
|
||||
const { from, to } = editor.state.selection;
|
||||
const content = editor.state.doc.textBetween(from, to);
|
||||
|
||||
setOutput("");
|
||||
setIsLoading(true);
|
||||
aiGenerateStreamMutation.mutate({
|
||||
action: command.action,
|
||||
prompt: command.prompt,
|
||||
content,
|
||||
onChunk: (chunk) => {
|
||||
setOutput((output) => output + chunk.content);
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsLoading(false);
|
||||
setActiveCommandSet("result");
|
||||
},
|
||||
onError: () => {
|
||||
setIsLoading(false);
|
||||
resetMenu();
|
||||
},
|
||||
});
|
||||
setLastAction(command);
|
||||
},
|
||||
[
|
||||
editor,
|
||||
prompt,
|
||||
isLoading,
|
||||
aiGenerateStreamMutation.mutateAsync,
|
||||
resetMenu,
|
||||
],
|
||||
);
|
||||
const handleCommand = useCallback(
|
||||
(item?: CommandItem) => {
|
||||
setPrompt("");
|
||||
|
||||
if (!item) {
|
||||
return handleGenerate();
|
||||
}
|
||||
if (item.id === "back") {
|
||||
return setActiveCommandSet("main");
|
||||
}
|
||||
if (item.id === "result-replace") {
|
||||
const chain = editor.chain().focus();
|
||||
|
||||
if (lastAction.action === AiAction.CONTINUE_WRITING) {
|
||||
chain.setTextSelection(editor.state.selection.to);
|
||||
}
|
||||
|
||||
chain.insertContent(marked.parse(output)).run();
|
||||
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-insert-below") {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(editor.state.selection.to)
|
||||
.insertContent(marked.parse(output))
|
||||
.run();
|
||||
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-copy") {
|
||||
navigator.clipboard.writeText(output);
|
||||
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
if (item.id === "result-discard") {
|
||||
setOutput("");
|
||||
|
||||
return resetMenu();
|
||||
}
|
||||
if (item.id === "result-try-again" && lastAction) {
|
||||
return handleGenerate(lastAction);
|
||||
}
|
||||
if (item.subCommandSet) {
|
||||
return setActiveCommandSet(item.subCommandSet);
|
||||
}
|
||||
|
||||
return handleGenerate(item);
|
||||
},
|
||||
[editor, output, lastAction, handleGenerate, resetMenu],
|
||||
);
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const totalItems = currentItems.length;
|
||||
const cycleSize = totalItems + 1;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
return setShowAiMenu(false);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
|
||||
return setSelectedIndex((selectedIndex) => {
|
||||
const direction = event.key === "ArrowDown" ? 1 : -1;
|
||||
const newIndex = selectedIndex + direction;
|
||||
|
||||
if (newIndex < -1) return cycleSize - 1;
|
||||
if (newIndex >= cycleSize) return 0;
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
|
||||
return handleCommand(currentItems[selectedIndex]);
|
||||
}
|
||||
},
|
||||
[currentItems, selectedIndex],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleClose = () => setShowAiMenu(false);
|
||||
const observer = new ResizeObserver(() => {
|
||||
debouncedUpdateMenuPlacement();
|
||||
});
|
||||
|
||||
updateMenuPlacement();
|
||||
editor.on("focus", handleClose);
|
||||
editor.on("blur", handleClose);
|
||||
window.addEventListener("resize", debouncedUpdateMenuPlacement);
|
||||
window.addEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
||||
observer.observe(editor.view.dom);
|
||||
|
||||
return () => {
|
||||
editor.off("focus", handleClose);
|
||||
editor.off("blur", handleClose);
|
||||
window.removeEventListener("resize", debouncedUpdateMenuPlacement);
|
||||
window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAiMenu) {
|
||||
resetMenu();
|
||||
}
|
||||
}, [showAiMenu, resetMenu]);
|
||||
useEffect(() => {
|
||||
// Focus input when menu opens or command set changes
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
}, [showAiMenu, isLoading, currentItems]);
|
||||
useEffect(() => {
|
||||
if (!currentItems.length) {
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1);
|
||||
}, [prompt, activeCommandSet, currentItems]);
|
||||
|
||||
if (!showAiMenu) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
zIndex: 200,
|
||||
position: "fixed",
|
||||
top: menuPlacement.top,
|
||||
left: menuPlacement.left,
|
||||
width: menuPlacement.width,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classes.aiMenu}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
tabIndex={0}
|
||||
ref={containerRef}
|
||||
>
|
||||
<ResultPreview output={output} isLoading={isLoading} />
|
||||
<CommandSelector
|
||||
selectedIndex={selectedIndex}
|
||||
isLoading={isLoading}
|
||||
output={output}
|
||||
currentItems={currentItems}
|
||||
handleCommand={handleCommand}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Ask AI..."
|
||||
data-autofocus
|
||||
value={prompt}
|
||||
disabled={isLoading}
|
||||
onChange={(e) => setPrompt(e.currentTarget.value)}
|
||||
rightSection={
|
||||
<Tooltip label="Ask AI">
|
||||
<ActionIcon
|
||||
disabled={!prompt || isLoading}
|
||||
variant="transparent"
|
||||
onClick={() => handleGenerate()}
|
||||
>
|
||||
<IconSend size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</CommandSelector>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export { EditorAiMenu };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { AiAction } from "@/ee/ai/types/ai.types";
|
||||
import {
|
||||
IconSparkles,
|
||||
IconArrowsMaximize,
|
||||
IconArrowsMinimize,
|
||||
IconWriting,
|
||||
IconHelp,
|
||||
IconList,
|
||||
IconMoodSmile,
|
||||
IconLanguage,
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconChevronLeft,
|
||||
IconCheck,
|
||||
IconArrowDownLeft,
|
||||
IconCopy,
|
||||
IconTextPlus,
|
||||
IconAlignJustified,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
interface CommandItem {
|
||||
name: string;
|
||||
id: string;
|
||||
icon?: typeof IconSparkles;
|
||||
action?: AiAction;
|
||||
prompt?: string;
|
||||
subCommandSet?: CommandSet;
|
||||
}
|
||||
|
||||
type CommandSet = "main" | "tone" | "translate" | "result";
|
||||
|
||||
const mainItems: CommandItem[] = [
|
||||
{
|
||||
id: "improve-writing",
|
||||
name: "Improve writing",
|
||||
icon: IconSparkles,
|
||||
action: AiAction.IMPROVE_WRITING,
|
||||
},
|
||||
{
|
||||
id: "fix-spelling-grammar",
|
||||
name: "Fix spelling & grammar",
|
||||
icon: IconCheck,
|
||||
action: AiAction.FIX_SPELLING_GRAMMAR,
|
||||
},
|
||||
{
|
||||
id: "make-longer",
|
||||
name: "Make longer",
|
||||
icon: IconTextPlus,
|
||||
action: AiAction.MAKE_LONGER,
|
||||
},
|
||||
{
|
||||
id: "make-shorter",
|
||||
name: "Make shorter",
|
||||
icon: IconAlignJustified,
|
||||
action: AiAction.MAKE_SHORTER,
|
||||
},
|
||||
{
|
||||
id: "continue-writing",
|
||||
name: "Continue writing",
|
||||
icon: IconWriting,
|
||||
action: AiAction.CONTINUE_WRITING,
|
||||
},
|
||||
{
|
||||
id: "explain",
|
||||
name: "Explain",
|
||||
icon: IconHelp,
|
||||
action: AiAction.CUSTOM,
|
||||
prompt: "Explain this text",
|
||||
},
|
||||
{
|
||||
id: "summarize",
|
||||
name: "Summarize",
|
||||
icon: IconList,
|
||||
action: AiAction.SUMMARIZE,
|
||||
},
|
||||
{
|
||||
id: "change-tone",
|
||||
name: "Change tone",
|
||||
icon: IconMoodSmile,
|
||||
subCommandSet: "tone",
|
||||
},
|
||||
{
|
||||
id: "translate",
|
||||
name: "Translate",
|
||||
icon: IconLanguage,
|
||||
subCommandSet: "translate",
|
||||
},
|
||||
];
|
||||
const toneItems: CommandItem[] = [
|
||||
{
|
||||
id: "back",
|
||||
name: "Back",
|
||||
icon: IconChevronLeft,
|
||||
},
|
||||
{
|
||||
id: "tone-professional",
|
||||
name: "Professional",
|
||||
icon: IconMoodSmile,
|
||||
action: AiAction.CHANGE_TONE,
|
||||
prompt: "Professional",
|
||||
},
|
||||
{
|
||||
id: "tone-casual",
|
||||
name: "Casual",
|
||||
icon: IconMoodSmile,
|
||||
action: AiAction.CHANGE_TONE,
|
||||
prompt: "Casual",
|
||||
},
|
||||
{
|
||||
id: "tone-friendly",
|
||||
name: "Friendly",
|
||||
icon: IconMoodSmile,
|
||||
action: AiAction.CHANGE_TONE,
|
||||
prompt: "Friendly",
|
||||
},
|
||||
];
|
||||
const translateItems: CommandItem[] = [
|
||||
{
|
||||
id: "back",
|
||||
name: "Back",
|
||||
icon: IconChevronLeft,
|
||||
},
|
||||
{
|
||||
id: "translate-english",
|
||||
name: "English",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "English",
|
||||
},
|
||||
{
|
||||
id: "translate-french",
|
||||
name: "French",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "French",
|
||||
},
|
||||
{
|
||||
id: "translate-german",
|
||||
name: "German",
|
||||
icon: IconLanguage,
|
||||
action: AiAction.TRANSLATE,
|
||||
prompt: "German",
|
||||
},
|
||||
];
|
||||
const resultItems: CommandItem[] = [
|
||||
{ id: "result-replace", name: "Replace", icon: IconCheck },
|
||||
{ id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft },
|
||||
{ id: "result-copy", name: "Copy", icon: IconCopy },
|
||||
{ id: "result-discard", name: "Discard", icon: IconTrash },
|
||||
{
|
||||
id: "result-try-again",
|
||||
name: "Try again",
|
||||
icon: IconRefresh,
|
||||
},
|
||||
];
|
||||
const commandItems: Record<CommandSet, CommandItem[]> = {
|
||||
main: mainItems,
|
||||
tone: toneItems,
|
||||
translate: translateItems,
|
||||
result: resultItems,
|
||||
};
|
||||
|
||||
export type { CommandItem, CommandSet };
|
||||
export { commandItems };
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { IconChevronRight } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { CommandItem } from "./command-items";
|
||||
import classes from "./ai-menu.module.css";
|
||||
|
||||
interface CommandSelectorProps {
|
||||
selectedIndex: number;
|
||||
|
||||
isLoading: boolean;
|
||||
output: string;
|
||||
currentItems: CommandItem[];
|
||||
children: ReactNode;
|
||||
handleCommand(item: CommandItem): void;
|
||||
}
|
||||
|
||||
const CommandSelector = ({
|
||||
selectedIndex,
|
||||
children,
|
||||
isLoading,
|
||||
output,
|
||||
currentItems,
|
||||
handleCommand,
|
||||
}: CommandSelectorProps) => {
|
||||
return (
|
||||
<Menu
|
||||
opened={!isLoading && currentItems.length > 0}
|
||||
position="bottom-start"
|
||||
offset={4}
|
||||
width={250}
|
||||
trapFocus={false}
|
||||
shadow="lg"
|
||||
>
|
||||
<Menu.Target>{children}</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
|
||||
{currentItems.map((item, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const showLoader =
|
||||
isLoading && output === "" && !item.subCommandSet;
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={item.id}
|
||||
className={isSelected ? classes.menuItemSelected : undefined}
|
||||
leftSection={
|
||||
showLoader ? (
|
||||
<Loader size={14} />
|
||||
) : item.icon ? (
|
||||
<item.icon size={16} />
|
||||
) : undefined
|
||||
}
|
||||
rightSection={
|
||||
item.subCommandSet ? (
|
||||
<IconChevronRight size={14} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => handleCommand(item)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{item.name}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export { CommandSelector };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Loader, Paper, Text } from "@mantine/core";
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { memo } from "react";
|
||||
import classes from "./ai-menu.module.css";
|
||||
|
||||
interface ResultPreviewProps {
|
||||
output: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => {
|
||||
if (!output && !isLoading) return;
|
||||
|
||||
const parsedOutput = `${marked.parse(output)}`;
|
||||
|
||||
return (
|
||||
<Paper p="sm" mb={4} shadow="lg" withBorder>
|
||||
<Text size="sm" component="div">
|
||||
{parsedOutput && (
|
||||
<div
|
||||
className={classes.resultPreviewWrapper}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
});
|
||||
|
||||
export { ResultPreview };
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
IconMessage,
|
||||
IconSparkles,
|
||||
} from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
|
||||
import { ColorSelector } from "./color-selector";
|
||||
import { NodeSelector } from "./node-selector";
|
||||
import { TextAlignmentSelector } from "./text-alignment-selector";
|
||||
@@ -25,6 +26,7 @@ import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -39,14 +41,20 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
const showAiMenuRef = useRef(showAiMenu);
|
||||
|
||||
useEffect(() => {
|
||||
showCommentPopupRef.current = showCommentPopup;
|
||||
}, [showCommentPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
showAiMenuRef.current = showAiMenu;
|
||||
}, [showAiMenu]);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor: props.editor,
|
||||
selector: (ctx) => {
|
||||
@@ -123,6 +131,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
empty ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
showAiMenuRef.current ||
|
||||
showCommentPopupRef?.current
|
||||
) {
|
||||
return false;
|
||||
@@ -146,9 +155,26 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
// Hide the bubble menu immediately when AI menu is shown
|
||||
if (showAiMenu) return;
|
||||
|
||||
return (
|
||||
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
style={{ zIndex: 200, position: "relative" }}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
<Button
|
||||
variant="default"
|
||||
style={{ border: "none", height: "34px" }}
|
||||
radius="0"
|
||||
rightSection={<IconSparkles size={16} />}
|
||||
onClick={() => {
|
||||
setShowAiMenu(true);
|
||||
}}
|
||||
>
|
||||
Ask AI
|
||||
</Button>
|
||||
<NodeSelector
|
||||
editor={props.editor}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
|
||||
@@ -66,6 +66,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorAiMenu } from "./components/ai-menu/ai-menu";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -405,6 +406,7 @@ export default function PageEditor({
|
||||
|
||||
{editor && editorIsEditable && (
|
||||
<div>
|
||||
<EditorAiMenu editor={editor} />
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
<TableMenu editor={editor} />
|
||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||
|
||||
@@ -26,9 +26,6 @@ import { getAppUrl, isCloud } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
|
||||
interface ShareModalProps {
|
||||
readOnly: boolean;
|
||||
@@ -43,12 +40,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
const { data: share } = useShareForPageQuery(pageId);
|
||||
const { spaceSlug } = useParams();
|
||||
const { isTrial } = useTrial();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const workspaceDisabled =
|
||||
workspace?.settings?.sharing?.disabled === true;
|
||||
const spaceDisabled = space?.settings?.sharing?.disabled === true;
|
||||
const sharingDisabled = workspaceDisabled || spaceDisabled;
|
||||
const createShareMutation = useCreateShareMutation();
|
||||
const updateShareMutation = useUpdateShareMutation();
|
||||
const deleteShareMutation = useDeleteShareMutation();
|
||||
@@ -173,20 +164,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
{t("Upgrade Plan")}
|
||||
</Button>
|
||||
</>
|
||||
) : sharingDisabled ? (
|
||||
<>
|
||||
<Group justify="center" mb="sm">
|
||||
<IconLock size={20} stroke={1.5} />
|
||||
</Group>
|
||||
<Text size="sm" ta="center" fw={500} mb="xs">
|
||||
{t("Public sharing is disabled")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{workspaceDisabled
|
||||
? t("Public sharing has been disabled at the workspace level.")
|
||||
: t("Public sharing has been disabled for this space.")}
|
||||
</Text>
|
||||
</>
|
||||
) : isDescendantShared ? (
|
||||
<>
|
||||
<Text size="sm">{t("Inherits public sharing from")}</Text>
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
ResponsiveSettingsControl,
|
||||
ResponsiveSettingsRow,
|
||||
} from "@/components/ui/responsive-settings-row.tsx";
|
||||
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
|
||||
|
||||
interface SpaceDetailsProps {
|
||||
spaceId: string;
|
||||
@@ -28,8 +26,6 @@ interface SpaceDetailsProps {
|
||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||
const hasEnterpriseAccess = useEnterpriseAccess();
|
||||
const showSharingToggle = !readOnly && hasEnterpriseAccess;
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [isIconUploading, setIsIconUploading] = useState(false);
|
||||
@@ -81,6 +77,7 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
fallbackName={space.name}
|
||||
size={"60px"}
|
||||
variant="filled"
|
||||
|
||||
type={AvatarIconType.SPACE_ICON}
|
||||
onUpload={handleIconUpload}
|
||||
onRemove={handleIconRemove}
|
||||
@@ -91,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||
|
||||
{showSharingToggle && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
<SpacePublicSharingToggle space={space} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
|
||||
@@ -5,14 +5,6 @@ import {
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
|
||||
export interface ISpaceSharingSettings {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -26,9 +18,6 @@ export interface ISpace {
|
||||
memberCount?: number;
|
||||
spaceId?: string;
|
||||
membership?: IMembership;
|
||||
settings?: ISpaceSettings;
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
|
||||
await api.post("/workspace/members/delete", data);
|
||||
}
|
||||
|
||||
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||
export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) {
|
||||
const req = await api.post<IWorkspace>("/workspace/update", data);
|
||||
return req.data;
|
||||
}
|
||||
@@ -66,9 +66,7 @@ export async function createInvitation(data: ICreateInvite) {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(
|
||||
data: IAcceptInvite,
|
||||
): Promise<{ requiresLogin?: boolean }> {
|
||||
export async function acceptInvitation(data: IAcceptInvite): Promise<{ requiresLogin?: boolean; }> {
|
||||
const req = await api.post("/workspace/invites/accept", data);
|
||||
return req.data;
|
||||
}
|
||||
@@ -110,3 +108,4 @@ export async function getAppVersion(): Promise<IVersion> {
|
||||
const req = await api.post("/version");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,23 +22,16 @@ export interface IWorkspace {
|
||||
plan?: string;
|
||||
hasLicenseKey?: boolean;
|
||||
enforceMfa?: boolean;
|
||||
aiSearch?: boolean;
|
||||
disablePublicSharing?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSettings {
|
||||
ai?: IWorkspaceAiSettings;
|
||||
sharing?: IWorkspaceSharingSettings;
|
||||
}
|
||||
|
||||
export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ICreateInvite {
|
||||
role: string;
|
||||
emails: string[];
|
||||
|
||||
@@ -92,9 +92,9 @@
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"pg-tsquery": "^8.4.2",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.8",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.8",
|
||||
"postmark": "^4.0.5",
|
||||
"react": "^18.3.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -64,18 +64,8 @@ export class ShareController {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
const shareData = await this.shareService.getSharedPage(dto, workspace.id);
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspace.id,
|
||||
shareData.share.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
throw new NotFoundException('Shared page not found');
|
||||
}
|
||||
|
||||
return {
|
||||
...shareData,
|
||||
...(await this.shareService.getSharedPage(dto, workspace.id)),
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
@@ -96,14 +86,6 @@ export class ShareController {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
share.workspaceId,
|
||||
share.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
@@ -145,14 +127,6 @@ export class ShareController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspace.id,
|
||||
page.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
throw new ForbiddenException('Public sharing is disabled');
|
||||
}
|
||||
|
||||
return this.shareService.createShare({
|
||||
page,
|
||||
authUserId: user.id,
|
||||
@@ -202,21 +176,8 @@ export class ShareController {
|
||||
@Body() dto: ShareIdDto,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const treeData = await this.shareService.getShareTree(
|
||||
dto.shareId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspace.id,
|
||||
treeData.share.spaceId,
|
||||
);
|
||||
if (!sharingAllowed) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
return {
|
||||
...treeData,
|
||||
...(await this.shareService.getShareTree(dto.shareId, workspace.id)),
|
||||
hasLicenseKey: hasLicenseOrEE({
|
||||
licenseKey: workspace.licenseKey,
|
||||
isCloud: this.environmentService.isCloud(),
|
||||
|
||||
@@ -264,31 +264,6 @@ export class ShareService {
|
||||
return ancestor;
|
||||
}
|
||||
|
||||
async isSharingAllowed(
|
||||
workspaceId: string,
|
||||
spaceId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.innerJoin('spaces', 'spaces.workspaceId', 'workspaces.id')
|
||||
.select([
|
||||
'workspaces.settings as workspaceSettings',
|
||||
'spaces.settings as spaceSettings',
|
||||
])
|
||||
.where('workspaces.id', '=', workspaceId)
|
||||
.where('spaces.id', '=', spaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!result) return false;
|
||||
|
||||
const workspaceDisabled =
|
||||
(result.workspaceSettings as any)?.sharing?.disabled === true;
|
||||
const spaceDisabled =
|
||||
(result.spaceSettings as any)?.sharing?.disabled === true;
|
||||
|
||||
return !workspaceDisabled && !spaceDisabled;
|
||||
}
|
||||
|
||||
async updatePublicAttachments(page: Page): Promise<any> {
|
||||
const prosemirrorJson = getProsemirrorContent(page.content);
|
||||
const attachmentIds = getAttachmentIds(prosemirrorJson);
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateSpaceDto } from './create-space.dto';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
spaceId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -18,18 +17,12 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
import { Queue } from 'bullmq';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
|
||||
@Injectable()
|
||||
export class SpaceService {
|
||||
constructor(
|
||||
private spaceRepo: SpaceRepo,
|
||||
private spaceMemberService: SpaceMemberService,
|
||||
private shareRepo: ShareRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
) {}
|
||||
@@ -112,31 +105,6 @@ export class SpaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.licenseCheckService.isValidEELicense(workspace.licenseKey)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateSharingSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateSpaceDto.disablePublicSharing,
|
||||
);
|
||||
|
||||
if (updateSpaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteBySpaceId(updateSpaceDto.spaceId);
|
||||
}
|
||||
}
|
||||
|
||||
return await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
|
||||
@@ -30,8 +30,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
generativeAi: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: boolean;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
|
||||
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
||||
import { SpaceService } from '../../space/services/space.service';
|
||||
@@ -34,7 +33,6 @@ import { Queue } from 'bullmq';
|
||||
import { generateRandomSuffixNumbers } from '../../../common/helpers';
|
||||
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
|
||||
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -49,8 +47,6 @@ export class WorkspaceService {
|
||||
private userRepo: UserRepo,
|
||||
private environmentService: EnvironmentService,
|
||||
private domainService: DomainService,
|
||||
private licenseCheckService: LicenseCheckService,
|
||||
private shareRepo: ShareRepo,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@@ -362,32 +358,6 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.generativeAi;
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.disablePublicSharing !== 'undefined') {
|
||||
const currentWorkspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.licenseCheckService.isValidEELicense(currentWorkspace.licenseKey)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid enterprise license',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateSharingSettings(
|
||||
workspaceId,
|
||||
'disabled',
|
||||
updateWorkspaceDto.disablePublicSharing,
|
||||
);
|
||||
|
||||
if (updateWorkspaceDto.disablePublicSharing) {
|
||||
await this.shareRepo.deleteByWorkspaceId(workspaceId);
|
||||
}
|
||||
|
||||
delete updateWorkspaceDto.disablePublicSharing;
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('spaces').addColumn('settings', 'jsonb').execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('spaces').dropColumn('settings').execute();
|
||||
}
|
||||
@@ -136,20 +136,6 @@ export class ShareRepo {
|
||||
await query.execute();
|
||||
}
|
||||
|
||||
async deleteBySpaceId(spaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('shares')
|
||||
.where('spaceId', '=', spaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByWorkspaceId(workspaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('shares')
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getShares(userId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('shares')
|
||||
|
||||
@@ -89,26 +89,6 @@ export class SpaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateSharingSettings(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
) {
|
||||
return this.db
|
||||
.updateTable('spaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', spaceId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async insertSpace(
|
||||
insertableSpace: InsertableSpace,
|
||||
trx?: KyselyTransaction,
|
||||
|
||||
@@ -167,7 +167,7 @@ export class WorkspaceRepo {
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|
||||
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -185,25 +185,7 @@ export class WorkspaceRepo {
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', workspaceId)
|
||||
.returning(this.baseFields)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateSharingSettings(
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
) {
|
||||
return this.db
|
||||
.updateTable('workspaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|
||||
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
-1
@@ -273,7 +273,6 @@ export interface Spaces {
|
||||
id: Generated<string>;
|
||||
logo: string | null;
|
||||
name: string | null;
|
||||
settings: Json | null;
|
||||
slug: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
visibility: Generated<string>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 6d3eb76d4e...3a4b47ec30
@@ -4,7 +4,6 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { validate } from './environment.validation';
|
||||
import { envPath } from '../../common/helpers';
|
||||
import { DomainService } from './domain.service';
|
||||
import { LicenseCheckService } from './license-check.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -16,7 +15,7 @@ import { LicenseCheckService } from './license-check.service';
|
||||
validate,
|
||||
}),
|
||||
],
|
||||
providers: [EnvironmentService, DomainService, LicenseCheckService],
|
||||
exports: [EnvironmentService, DomainService, LicenseCheckService],
|
||||
providers: [EnvironmentService, DomainService],
|
||||
exports: [EnvironmentService, DomainService],
|
||||
})
|
||||
export class EnvironmentModule {}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { EnvironmentService } from './environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class LicenseCheckService {
|
||||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
isValidEELicense(licenseKey: 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.isValidEELicense(licenseKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user