Merge branch 'main' into fix/conf-anchor

This commit is contained in:
Philipinho
2026-03-04 15:44:47 +00:00
432 changed files with 28514 additions and 8797 deletions
+4 -8
View File
@@ -14,7 +14,6 @@ import AccountPreferences from "@/pages/settings/account/account-preferences.tsx
import SpaceHome from "@/pages/space/space-home.tsx";
import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
@@ -38,6 +37,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
export default function App() {
const { t } = useTranslation();
@@ -84,13 +84,7 @@ export default function App() {
<Route path={"/s/:spaceSlug/trash"} element={<SpaceTrash />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
element={
<ErrorBoundary
fallback={<>{t("Failed to load page. An error occurred.")}</>}
>
<Page />
</ErrorBoundary>
}
element={<Page />}
/>
<Route path={"/settings"}>
@@ -109,6 +103,8 @@ export default function App() {
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -130,7 +130,7 @@ export default function AvatarUploader({
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
zIndex: 200,
}}
>
<Loader size="sm" />
@@ -0,0 +1,33 @@
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/core/src/components/CopyButton/CopyButton.tsx - MIT
// modified to use the polyfilled clipboard api
import React from "react";
import { useClipboard } from "@/hooks/use-clipboard";
import { useProps } from "@mantine/core";
interface CopyButtonProps {
/** Children callback, provides current status and copy function as an argument */
children: (payload: { copied: boolean; copy: () => void }) => React.ReactNode;
/** Value that is copied to the clipboard when the button is clicked */
value: string;
/** Copied status timeout in ms @default `1000` */
timeout?: number;
}
const defaultProps = {
timeout: 1000,
} satisfies Partial<CopyButtonProps>;
export function CopyButton(props: CopyButtonProps) {
const { children, timeout, value, ...others } = useProps(
"CopyButton",
defaultProps,
props,
);
const clipboard = useClipboard({ timeout });
const copy = () => clipboard.copy(value);
return <>{children({ copy, copied: clipboard.copied, ...others })}</>;
}
CopyButton.displayName = "@mantine/core/CopyButton";
+2 -1
View File
@@ -1,4 +1,5 @@
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { ActionIcon, Tooltip } from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -2,17 +2,17 @@ import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
export interface PagePaginationProps {
currentPage: number;
hasPrevPage: boolean;
hasNextPage: boolean;
onPageChange: (newPage: number) => void;
onPrev: () => void;
onNext: () => void;
}
export default function Paginate({
currentPage,
hasPrevPage,
hasNextPage,
onPageChange,
onPrev,
onNext,
}: PagePaginationProps) {
const { t } = useTranslation();
@@ -25,7 +25,7 @@ export default function Paginate({
<Button
variant="default"
size="compact-sm"
onClick={() => onPageChange(currentPage - 1)}
onClick={onPrev}
disabled={!hasPrevPage}
>
{t("Prev")}
@@ -34,7 +34,7 @@ export default function Paginate({
<Button
variant="default"
size="compact-sm"
onClick={() => onPageChange(currentPage + 1)}
onClick={onNext}
disabled={!hasNextPage}
>
{t("Next")}
@@ -11,7 +11,8 @@ import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { IconFileDescription, IconFiles } from "@tabler/icons-react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color.ts";
@@ -85,8 +86,10 @@ export default function RecentChanges({ spaceId }: Props) {
</Table>
</Table.ScrollContainer>
) : (
<Text size="md" ta="center">
{t("No pages yet")}
</Text>
<EmptyState
icon={IconFiles}
title={t("No pages yet")}
description={t("Pages you create will show up here.")}
/>
);
}
@@ -0,0 +1,27 @@
import { rem } from "@mantine/core";
type Props = {
size?: number | string;
stroke?: number;
};
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
<path d="M7.5 3v18" />
<path d="M12 3v18" />
<path d="M16.5 3v18" />
</svg>
);
}
@@ -0,0 +1,28 @@
import { rem } from "@mantine/core";
type Props = {
size?: number | string;
stroke?: number;
};
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
<path d="M6.6 3v18" />
<path d="M10.2 3v18" />
<path d="M13.8 3v18" />
<path d="M17.4 3v18" />
</svg>
);
}
@@ -22,6 +22,7 @@ import {
searchSpotlight,
shareSearchSpotlight,
} from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@@ -97,6 +98,7 @@ export function AppHeader() {
</div>
<Group px={"xl"} wrap="nowrap">
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
variant="light"
@@ -31,7 +31,7 @@ export default function Aside() {
}
return (
<Box p="md">
<Box p="md" style={{ height: "100%", display: "flex", flexDirection: "column" }}>
{component && (
<>
<Text mb="md" fw={500}>
@@ -11,9 +11,10 @@ import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
const params: QueryParams = { limit: 100, query: "" };
queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params),
@@ -22,15 +23,15 @@ export const prefetchWorkspaceMembers = () => {
export const prefetchSpaces = () => {
queryClient.prefetchQuery({
queryKey: ["spaces", { page: 1 }],
queryFn: () => getSpaces({ page: 1 }),
queryKey: ["spaces", {}],
queryFn: () => getSpaces({}),
});
};
export const prefetchGroups = () => {
queryClient.prefetchQuery({
queryKey: ["groups", { page: 1 }],
queryFn: () => getGroups({ page: 1 }),
queryKey: ["groups", {}],
queryFn: () => getGroups({}),
});
};
@@ -62,21 +63,29 @@ export const prefetchSsoProviders = () => {
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({ page: 1, limit: 100 }),
queryKey: ["share-list", {}],
queryFn: () => getShares({}),
});
};
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1 }),
queryKey: ["api-key-list", {}],
queryFn: () => getApiKeys({}),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1, adminView: true }),
queryKey: ["api-key-list", { adminView: true }],
queryFn: () => getApiKeys({ adminView: true }),
});
};
export const prefetchAuditLogs = () => {
const params = { limit: 50 };
queryClient.prefetchQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
});
};
@@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconHistory,
} from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
@@ -31,6 +32,7 @@ import {
prefetchSpaces,
prefetchSsoProviders,
prefetchWorkspaceMembers,
prefetchAuditLogs,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -44,6 +46,7 @@ interface DataItem {
isCloud?: boolean;
isEnterprise?: boolean;
isAdmin?: boolean;
isOwner?: boolean;
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
@@ -115,7 +118,15 @@ const groupedData: DataGroup[] = [
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
},
{
label: "Audit log",
icon: IconHistory,
path: "/settings/audit",
isEnterprise: true,
isOwner: true,
isSelfhosted: true,
showDisabledInNonEE: true,
},
],
},
@@ -136,7 +147,7 @@ export default function SettingsSidebar() {
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const { goBack } = useSettingsNavigation();
const { isAdmin } = useUserRole();
const { isAdmin, isOwner } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
@@ -145,34 +156,36 @@ export default function SettingsSidebar() {
setActive(location.pathname);
}, [location.pathname]);
const hasRoleAccess = (item: DataItem) => {
if (item.isOwner) return isOwner;
if (item.isAdmin) return isAdmin;
return true;
};
const canShowItem = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) {
// Check admin permission regardless of license
return item.isAdmin ? isAdmin : true;
if (item.isSelfhosted && isCloud()) return false;
return hasRoleAccess(item);
}
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return item.isAdmin ? isAdmin : true;
return hasRoleAccess(item);
}
if (item.isCloud) {
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
return isCloud() ? hasRoleAccess(item) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
return !isCloud() ? hasRoleAccess(item) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
return workspace?.hasLicenseKey ? hasRoleAccess(item) : false;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
return hasRoleAccess(item);
};
const isItemDisabled = (item: DataItem) => {
@@ -228,6 +241,9 @@ export default function SettingsSidebar() {
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
default:
break;
}
@@ -4,7 +4,7 @@ import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl: string;
avatarUrl?: string;
name: string;
color?: string;
size?: string | number;
@@ -0,0 +1,8 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
@@ -0,0 +1,30 @@
import { Stack, Text } from "@mantine/core";
import { type TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react";
import classes from "./empty-state.module.css";
type EmptyStateProps = {
icon: TablerIcon;
title: string;
description?: string;
action?: ReactNode;
};
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className={classes.root}>
<Stack align="center" gap="xs">
<Icon size={40} stroke={1.5} color="var(--mantine-color-dimmed)" />
<Text size="lg" fw={500}>
{title}
</Text>
{description && (
<Text size="sm" c="dimmed" maw={350}>
{description}
</Text>
)}
{action}
</Stack>
</div>
);
}
@@ -0,0 +1,61 @@
.aiMenu {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
min-height: 2.25rem;
}
.aiInput {
width: 100%;
& input {
height: 44px;
border-radius: 22px;
padding-left: 20px;
padding-right: 40px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: var(--mantine-font-size-sm);
&:focus {
border-color: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-3)
);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
}
}
.menuItemSelected {
background-color: var(--mantine-color-gray-1);
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.resultPreview {
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.resultPreviewWrapper {
font-size: var(--mantine-font-size-md);
line-height: 1.6;
padding: var(--mantine-spacing-md);
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}
@@ -0,0 +1,349 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput } 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 { IconArrowUp } from "@tabler/icons-react";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
import { AiAction } from "@/ee/ai/types/ai.types.ts";
import { CommandItem, commandItems, CommandSet } from "./command-items.ts";
import { CommandSelector } from "./command-selector.tsx";
import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { copyToClipboard, htmlToMarkdown } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
editor: Editor | null;
}
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
const location = useLocation();
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 { from, to } = editor.state.selection;
const editorRect = view.dom.getBoundingClientRect();
const fromCoords = view.coordsAtPos(from);
const toCoords = view.coordsAtPos(to);
const topOffset = 8;
const editorPadding = isSmBreakpoint ? 16 : 48;
const anchorBottom =
toCoords.bottom > 0 && toCoords.bottom < window.innerHeight
? toCoords.bottom
: fromCoords.bottom;
const menuMaxWidth = 600;
const editorLeft = editorRect.left + editorPadding;
const editorRight = editorRect.right - editorPadding;
const availableWidth = editorRight - editorLeft;
const menuWidth = Math.min(menuMaxWidth, availableWidth);
let menuLeft = Math.max(editorLeft, fromCoords.left);
if (menuLeft + menuWidth > editorRight) {
menuLeft = editorRight - menuWidth;
}
menuLeft = Math.max(editorLeft, menuLeft);
setMenuPlacement({
top: anchorBottom + topOffset + window.scrollY,
left: menuLeft + window.scrollX,
width: menuWidth,
});
}, [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 slice = editor.state.doc.slice(from, to);
const serializer = DOMSerializer.fromSchema(editor.schema);
const fragment = serializer.serializeFragment(slice.content);
const wrapper = document.createElement("div");
wrapper.appendChild(fragment);
const content = htmlToMarkdown(wrapper.innerHTML);
setOutput("");
setIsLoading(true);
aiGenerateStreamMutation.mutate({
action: command.action,
prompt: command.prompt,
content,
onChunk: (chunk) => {
setOutput((output) => output + chunk.content);
},
onComplete: () => {
setPrompt("");
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);
}
const html = (marked.parse(output) as string).trim();
const isSingleParagraph =
html.startsWith("<p>") &&
html.endsWith("</p>") &&
html.lastIndexOf("<p>") === 0;
// Strip <p> wrapper for single-paragraph output to preserve inline context,
// then decode HTML entities via DOMParser since TipTap would otherwise
// treat the tagless string as plain text and insert entities literally.
const content = isSingleParagraph
? new DOMParser().parseFromString(html.slice(3, -4), "text/html")
.body.innerHTML
: html;
chain.insertContent(content).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") {
copyToClipboard(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(() => {
setShowAiMenu(false);
}, [location]);
useEffect(() => {
if (showAiMenu) {
resetMenu();
}
}, [showAiMenu, resetMenu]);
useEffect(() => {
// Focus input when menu opens or command set changes
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true });
});
}, [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: 199,
position: "absolute",
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}
className={classes.aiInput}
placeholder="Ask AI..."
data-autofocus
value={prompt}
disabled={isLoading}
onChange={(e) => setPrompt(e.currentTarget.value)}
rightSection={
<ActionIcon
disabled={!prompt || isLoading}
variant="filled"
color="blue"
radius="xl"
size="sm"
onClick={() => handleGenerate()}
>
<IconArrowUp size={14} stroke={2.5} />
</ActionIcon>
}
onKeyDown={handleKeyDown}
/>
</CommandSelector>
</div>
</div>,
document.body,
);
};
export { EditorAiMenu };
@@ -0,0 +1,219 @@
import { AiAction } from "@/ee/ai/types/ai.types.ts";
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.EXPLAIN,
},
{
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-spanish",
name: "Spanish",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Spanish",
},
{
id: "translate-german",
name: "German",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "German",
},
{
id: "translate-french",
name: "French",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "French",
},
{
id: "translate-dutch",
name: "Dutch",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Dutch",
},
{
id: "translate-portuguese",
name: "Portuguese",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Portuguese",
},
{
id: "translate-italian",
name: "Italian",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Italian",
},
{
id: "translate-japanese",
name: "Japanese",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Japanese",
},
{
id: "translate-korean",
name: "Korean",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Korean",
},
{
id: "translate-swedish",
name: "Swedish",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Swedish",
},
{
id: "translate-chinese",
name: "Chinese (Simplified)",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Simplified Chinese",
},
];
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,72 @@
import { Loader, Menu, ScrollArea } from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import { ReactNode } from "react";
import { CommandItem } from "./command-items.ts";
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}
middlewares={{ flip: false }}
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,32 @@
import { Loader, Paper, ScrollArea } 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 mb={4} shadow="lg" radius="md" className={classes.resultPreview}>
<ScrollArea.Autosize mah={300} type="scroll" scrollbarSize={5}>
<div className={classes.resultPreviewWrapper}>
{parsedOutput && (
<div
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
/>
)}
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
</div>
</ScrollArea.Autosize>
</Paper>
);
});
export { ResultPreview };
@@ -15,7 +15,7 @@ export default function EnableAiSearch() {
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
<Text size="md">{t("AI-powered search (AI Answers)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
@@ -0,0 +1,48 @@
import { Group, Text, Switch } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function EnableGenerativeAi() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useIsCloudEE();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ generativeAi: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Generative AI (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
)}
</Text>
</div>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
);
}
@@ -0,0 +1,138 @@
import {
Anchor,
Group,
List,
Text,
Switch,
TextInput,
ActionIcon,
Tooltip,
Stack,
Alert,
} 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 { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useIsCloudEE();
const mcpUrl = `${getAppUrl()}/mcp`;
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ mcpEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Stack gap="lg">
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
>
{t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Model Context Protocol (MCP)")}</Text>
<Text size="sm" c="dimmed">
{t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
)}{" "}
{t("View the")}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
target="_blank"
size="sm"
>
{t("MCP documentation")}
</Anchor>
.
</Text>
</div>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
{checked && (
<div>
<Text size="sm" fw={500} mb={4}>
{t("MCP Server URL")}
</Text>
<Group gap="xs">
<TextInput
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t(
"Use your API key for authentication. You can manage API keys in your account settings.",
)}
</Text>
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Supported tools")}
</Text>
<List size="sm" spacing={2}>
<List.Item><Text size="sm" c="dimmed" span>search_pages, get_page, create_page, update_page</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>list_pages, list_child_pages, duplicate_page</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>copy_page_to_space, move_page, move_page_to_space</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>get_space, list_spaces, create_space, update_space</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>get_comments, create_comment, update_comment</Text></List.Item>
<List.Item><Text size="sm" c="dimmed" span>search_attachments, list_workspace_members, get_current_user</Text></List.Item>
</List>
</div>
</div>
)}
</Stack>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
@@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult {
const { contentType, ...apiParams } = params;
return await askAi(apiParams, (chunk) => {
return await aiAnswers(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
+53 -19
View File
@@ -1,46 +1,80 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import { Alert } from "@mantine/core";
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 { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
const hasAccess = useIsCloudEE();
const location = useLocation();
const navigate = useNavigate();
const activeTab = location.pathname.endsWith("/mcp") ? "mcp" : "ai";
if (!isAdmin) {
return null;
}
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleTabChange = (value: string | null) => {
if (value === "mcp") {
navigate("/settings/ai/mcp");
} else {
navigate("/settings/ai");
}
};
return (
<>
<Helmet>
<title>AI - {getAppName()}</title>
<title>AI settings - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="ai">
{t("AI")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="mcp">
{t("MCP")}
</Tabs.Tab>
</Tabs.List>
<EnableAiSearch />
<Tabs.Panel value="ai" pt="md">
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
</Tabs.Panel>
<Tabs.Panel value="mcp" pt="md">
<McpSettings />
</Tabs.Panel>
</Tabs>
</>
);
}
@@ -15,11 +15,11 @@ export interface IAiSearchResponse {
}>;
}
export async function askAi(
export async function aiAnswers(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/ask", {
const response = await fetch("/api/ai/answers", {
method: "POST",
headers: {
"Content-Type": "application/json",
+6 -3
View File
@@ -43,13 +43,16 @@ export async function generateAiContentStream(
}
const processStream = async () => {
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
@@ -66,7 +69,7 @@ export async function generateAiContentStream(
onChunk(parsed);
}
} catch (e) {
// Ignore parse errors for incomplete chunks
// Skip invalid JSON
}
}
}
+1
View File
@@ -6,6 +6,7 @@ export enum AiAction {
SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize",
EXPLAIN = "explain",
CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate",
CUSTOM = "custom",
@@ -1,8 +1,8 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
@@ -36,7 +36,7 @@ export function CreateApiKeyModal({
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
@@ -0,0 +1,68 @@
import { 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 useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
export default function RestrictApiToAdmins() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true,
);
const hasAccess = useEnterpriseAccess();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({
restrictApiToAdmins: value,
});
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">
{t("Restrict API key creation to admins")}
</Text>
<Text size="sm" c="dimmed">
{t(
"Only admins and owners can create new API keys. Existing member keys will continue to work.",
)}
</Text>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle restrict API keys to admins")}
/>
</Tooltip>
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
);
}
@@ -1,7 +1,7 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
@@ -27,7 +27,7 @@ export function UpdateApiKeyModal({
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
},
@@ -1,28 +1,37 @@
import React, { useState } from "react";
import { Button, Group, Space } from "@mantine/core";
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 SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const { cursor, goNext, goPrev } = useCursorPaginate();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page });
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin;
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
@@ -48,11 +57,50 @@ export default function UserApiKeys() {
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<Text size="sm" c="dimmed" mb="md">
{t("View the")}{" "}
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
{t("API documentation")}
</Anchor>{" "}
{t("for usage details.")}
</Text>
{mcpEnabled && canCreate && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t(
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
)}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
target="_blank"
size="sm"
>
{t("Learn more")}
</Anchor>
</Text>
<Text size="sm" mt={4}>
{t("MCP server URL:")}{" "}
<Text size="sm" fw={500} span ff="monospace">
{`${getAppUrl()}/mcp`}
</Text>
</Text>
</Alert>
)}
{canCreate ? (
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
) : restrictToAdmins ? (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
) : null}
<ApiKeyTable
apiKeys={data?.items || []}
@@ -65,10 +113,10 @@ export default function UserApiKeys() {
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Button, Group, Space, Text } from "@mantine/core";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
@@ -10,20 +10,21 @@ import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-moda
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
import RestrictApiToAdmins from "@/ee/api-key/components/restrict-api-to-admins";
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const { cursor, goNext, goPrev } = useCursorPaginate();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { data, isLoading } = useGetApiKeysQuery({ cursor, adminView: true });
const { isAdmin } = useUserRole();
if (!isAdmin) {
@@ -54,10 +55,18 @@ export default function WorkspaceApiKeys() {
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
<Text size="sm" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace.")}{" "}
{t("View the")}{" "}
<Anchor href="https://docmost.com/api-docs" target="_blank" size="sm">
{t("API documentation")}
</Anchor>{" "}
{t("for usage details.")}
</Text>
<RestrictApiToAdmins />
<Divider my="lg" />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
@@ -76,10 +85,10 @@ export default function WorkspaceApiKeys() {
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
@@ -0,0 +1,333 @@
import { Fragment, useState } from "react";
import {
Table,
Text,
Group,
Skeleton,
Anchor,
Collapse,
Box,
} from "@mantine/core";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
IconChevronRight,
IconChevronDown,
IconArrowRight,
} from "@tabler/icons-react";
import { IAuditLog } from "@/ee/audit/types/audit.types";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { getEventLabel } from "@/ee/audit/lib/audit-event-labels";
import { formattedDate } from "@/lib/time";
import NoTableResults from "@/components/common/no-table-results";
import classes from "./audit-logs.module.css";
type AuditLogsTableProps = {
items?: IAuditLog[];
isLoading: boolean;
};
function hasDetails(entry: IAuditLog): boolean {
return !!(entry.changes?.before || entry.changes?.after || entry.metadata);
}
function getResourceUrl(entry: IAuditLog): string | null {
if (!entry.resource) return null;
switch (entry.resourceType) {
case "group":
return `/settings/groups/${entry.resource.id}`;
case "space":
case "space_member":
return entry.resource.slug ? `/s/${entry.resource.slug}` : null;
default:
return null;
}
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function ChangesDiff({ changes }: { changes: IAuditLog["changes"] }) {
const { t } = useTranslation();
if (!changes) return null;
const { before, after } = changes;
const allKeys = new Set([
...Object.keys(before ?? {}),
...Object.keys(after ?? {}),
]);
if (allKeys.size === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Changes")}
</Text>
{[...allKeys].map((key) => {
const hasBefore = before && key in before;
const hasAfter = after && key in after;
return (
<Group key={key} gap={6} mb={2} wrap="nowrap" align="center">
<Text
fz="xs"
c="dimmed"
fw={500}
style={{ minWidth: "fit-content" }}
>
{key}:
</Text>
{hasBefore && (
<Text fz="xs" component="span">
{formatValue(before[key])}
</Text>
)}
{hasBefore && hasAfter && (
<IconArrowRight size={10} color="var(--mantine-color-dimmed)" />
)}
{hasAfter && (
<Text fz="xs" component="span">
{formatValue(after[key])}
</Text>
)}
</Group>
);
})}
</Box>
);
}
function MetadataDisplay({ metadata }: { metadata: Record<string, any> }) {
const { t } = useTranslation();
const entries = Object.entries(metadata);
if (entries.length === 0) return null;
return (
<Box>
<Text fz="xs" fw={600} mb={4}>
{t("Metadata")}
</Text>
{entries.map(([key, value]) => (
<Group key={key} gap={6} mb={2} wrap="nowrap">
<Text fz="xs" c="dimmed" fw={500}>
{key}:
</Text>
<Text fz="xs">{formatValue(value)}</Text>
</Group>
))}
</Box>
);
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 8 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Skeleton circle height={36} />
<div>
<Skeleton height={14} width={120} mb={4} />
<Skeleton height={10} width={160} />
</div>
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={120} />
</Table.Td>
</Table.Tr>
))}
</>
);
}
function ResourceCell({ entry }: { entry: IAuditLog }) {
if (!entry.resource?.name) {
return (
<Text fz="sm" c="dimmed">
</Text>
);
}
const url = getResourceUrl(entry);
if (url) {
return (
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={url}
>
<div className={classes.resourceLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.resource.name}
</Text>
</div>
</Anchor>
);
}
return (
<Text fz="sm" lineClamp={1}>
{entry.resource.name}
</Text>
);
}
export default function AuditLogsTable({
items,
isLoading,
}: AuditLogsTableProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<Table.ScrollContainer minWidth={700}>
<Table highlightOnHover verticalSpacing="xs" className={classes.table}>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Actor")}</Table.Th>
<Table.Th>{t("Event")}</Table.Th>
<Table.Th>{t("Resource")}</Table.Th>
<Table.Th>{t("Date")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : items && items.length > 0 ? (
items.map((entry) => {
const expandable = hasDetails(entry);
const isExpanded = expanded.has(entry.id);
return (
<Fragment key={entry.id}>
<Table.Tr
onClick={
expandable ? () => toggleExpanded(entry.id) : undefined
}
style={{ cursor: expandable ? "pointer" : undefined }}
>
<Table.Td>
<Group gap="sm" wrap="nowrap">
{expandable ? (
isExpanded ? (
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
/>
) : (
<IconChevronRight
size={16}
color="var(--mantine-color-dimmed)"
/>
)
) : (
<Box w={16} />
)}
{entry.actor ? (
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={entry.actor.avatarUrl}
name={entry.actor.name}
size={36}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{entry.actor.name}
</Text>
<Text fz="xs" c="dimmed">
{entry.actor.email}
</Text>
</div>
</Group>
) : (
<Text fz="sm" c="dimmed" fs="italic">
{entry.actorType === "system"
? t("System")
: t("System")}
</Text>
)}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm">{t(getEventLabel(entry.event))}</Text>
</Table.Td>
<Table.Td>
<ResourceCell entry={entry} />
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formattedDate(new Date(entry.createdAt))}
</Text>
</Table.Td>
</Table.Tr>
{expandable && (
<Table.Tr className={classes.detailRow}>
<Table.Td colSpan={4} p={0}>
<Collapse in={isExpanded}>
<Box
px="md"
py="sm"
className={classes.detailContent}
>
<Group gap="xl" align="flex-start">
{entry.changes && (
<ChangesDiff changes={entry.changes} />
)}
{entry.metadata && (
<MetadataDisplay metadata={entry.metadata} />
)}
</Group>
</Box>
</Collapse>
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})
) : (
<NoTableResults colSpan={4} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,33 @@
.table {
--table-border-color: var(--mantine-color-gray-2);
@mixin dark {
--table-border-color: var(--mantine-color-dark-5);
}
}
.resourceLinkText {
width: fit-content;
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
}
.detailRow {
&:hover {
background: none !important;
}
}
.detailContent {
@mixin light {
background: var(--mantine-color-gray-0);
}
@mixin dark {
background: var(--mantine-color-dark-7);
}
}
@@ -0,0 +1,170 @@
type EventOption = {
value: string;
label: string;
};
type EventGroup = {
group: string;
items: EventOption[];
};
export const auditEventLabels: Record<string, string> = {
"workspace.created": "Created workspace",
"workspace.updated": "Updated workspace",
"workspace.invite_created": "Created invitation",
"workspace.invite_resent": "Resent invitation",
"workspace.invite_revoked": "Revoked invitation",
"user.created": "Created user",
"user.deleted": "Deleted user",
"user.login": "Logged in",
"user.logout": "Logged out",
"user.role_changed": "Changed user role",
"user.password_changed": "Changed password",
"user.password_reset": "Reset password",
"user.updated": "Updated user",
"user.deactivated": "Deactivated user",
"user.activated": "Activated user",
"user.mfa_enabled": "Enabled MFA",
"user.mfa_disabled": "Disabled MFA",
"user.mfa_backup_code_generated": "Generated MFA backup codes",
"api_key.created": "Created API key",
"api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key",
"space.created": "Created space",
"space.updated": "Updated space",
"space.deleted": "Deleted space",
"space.member_added": "Added space member",
"space.member_removed": "Removed space member",
"space.member_role_changed": "Changed space member role",
"space.exported": "Exported space",
"group.created": "Created group",
"group.updated": "Updated group",
"group.deleted": "Deleted group",
"group.member_added": "Added group member",
"group.member_removed": "Removed group member",
"comment.deleted": "Deleted comment",
"page.trashed": "Trashed page",
"page.deleted": "Deleted page",
"page.restored": "Restored page",
"page.imported": "Imported page",
"page.exported": "Exported page",
"page.restricted": "Restricted page",
"page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission",
"share.created": "Created share link",
"share.deleted": "Deleted share link",
"sso.provider_created": "Created SSO provider",
"sso.provider_updated": "Updated SSO provider",
"sso.provider_deleted": "Deleted SSO provider",
"license.activated": "Activated license",
"license.removed": "Removed license",
};
export function getEventLabel(event: string): string {
return auditEventLabels[event] ?? event;
}
export const eventFilterOptions: EventGroup[] = [
{
group: "Workspace",
items: [
{ value: "workspace.updated", label: "Updated workspace" },
{ value: "workspace.invite_created", label: "Created invitation" },
{ value: "workspace.invite_revoked", label: "Revoked invitation" },
],
},
{
group: "User",
items: [
{ value: "user.login", label: "Logged in" },
{ value: "user.logout", label: "Logged out" },
{ value: "user.created", label: "Created user" },
{ value: "user.deleted", label: "Deleted user" },
{ value: "user.deactivated", label: "Deactivated user" },
{ value: "user.activated", label: "Activated user" },
{ value: "user.role_changed", label: "Changed user role" },
{ value: "user.password_changed", label: "Changed password" },
{ value: "user.mfa_enabled", label: "Enabled MFA" },
{ value: "user.mfa_disabled", label: "Disabled MFA" },
],
},
{
group: "Space",
items: [
{ value: "space.created", label: "Created space" },
{ value: "space.updated", label: "Updated space" },
{ value: "space.deleted", label: "Deleted space" },
{ value: "space.member_added", label: "Added space member" },
{ value: "space.member_removed", label: "Removed space member" },
],
},
{
group: "Group",
items: [
{ value: "group.created", label: "Created group" },
{ value: "group.updated", label: "Updated group" },
{ value: "group.deleted", label: "Deleted group" },
{ value: "group.member_added", label: "Added group member" },
{ value: "group.member_removed", label: "Removed group member" },
],
},
{
group: "Comment",
items: [
{ value: "comment.deleted", label: "Deleted comment" },
],
},
{
group: "Page",
items: [
{ value: "page.trashed", label: "Trashed page" },
{ value: "page.deleted", label: "Deleted page" },
{ value: "page.restored", label: "Restored page" },
{ value: "page.imported", label: "Imported page" },
{ value: "page.exported", label: "Exported page" },
{ value: "page.restricted", label: "Restricted page" },
{ value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed page permission" },
],
},
{
group: "Share",
items: [
{ value: "share.created", label: "Created share link" },
{ value: "share.deleted", label: "Deleted share link" },
],
},
{
group: "SSO",
items: [
{ value: "sso.provider_created", label: "Created SSO provider" },
{ value: "sso.provider_updated", label: "Updated SSO provider" },
{ value: "sso.provider_deleted", label: "Deleted SSO provider" },
],
},
{
group: "API key",
items: [
{ value: "api_key.created", label: "Created API key" },
{ value: "api_key.deleted", label: "Deleted API key" },
],
},
{
group: "License",
items: [
{ value: "license.activated", label: "Activated license" },
{ value: "license.removed", label: "Removed license" },
],
},
];
@@ -0,0 +1,223 @@
import { useState, useMemo, useEffect } from "react";
import {
ActionIcon,
Button,
Group,
NumberInput,
Popover,
Select,
Space,
Text,
Tooltip,
} from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import Paginate from "@/components/common/paginate";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import {
useAuditLogsQuery,
useAuditRetentionQuery,
useUpdateAuditRetentionMutation,
} from "@/ee/audit/queries/audit-query";
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import useUserRole from "@/hooks/use-user-role";
type RetentionUnit = "days" | "months" | "years";
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function AuditLogs() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const { data: retentionData } = useAuditRetentionQuery();
const updateRetention = useUpdateAuditRetentionMutation();
const currentDays = retentionData?.retentionDays ?? 365;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
useEffect(() => {
if (retentionData) {
const { amount, unit } = daysToRetention(retentionData.retentionDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
}
}, [retentionData?.retentionDays]);
const resetRetentionForm = () => {
const { amount, unit } = daysToRetention(currentDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
};
const params: IAuditLogParams = useMemo(
() => ({
cursor,
limit: 50,
event: eventFilter ?? undefined,
}),
[cursor, eventFilter],
);
const { data, isLoading } = useAuditLogsQuery(params);
if (!isOwner) {
return null;
}
const handleEventChange = (value: string | null) => {
setEventFilter(value);
resetCursor();
};
return (
<>
<Helmet>
<title>
{t("Audit log")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Audit log")} />
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
@@ -0,0 +1,51 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getAuditLogs,
getAuditRetention,
updateAuditRetention,
} from "@/ee/audit/services/audit-service";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useAuditLogsQuery(
params?: IAuditLogParams,
): UseQueryResult<IPagination<IAuditLog>, Error> {
return useQuery({
queryKey: ["audit-logs", params],
queryFn: () => getAuditLogs(params),
placeholderData: keepPreviousData,
});
}
export function useAuditRetentionQuery() {
return useQuery({
queryKey: ["audit-retention"],
queryFn: () => getAuditRetention(),
});
}
export function useUpdateAuditRetentionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (data: { auditRetentionDays: number }) =>
updateAuditRetention(data),
onSuccess: () => {
notifications.show({ message: t("Audit retention updated") });
queryClient.invalidateQueries({ queryKey: ["audit-retention"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,22 @@
import api from "@/lib/api-client";
import { IAuditLog, IAuditLogParams } from "@/ee/audit/types/audit.types";
import { IPagination } from "@/lib/types";
export async function getAuditLogs(
params?: IAuditLogParams,
): Promise<IPagination<IAuditLog>> {
const req = await api.post("/audit", { ...params });
return req.data;
}
export async function getAuditRetention(): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention");
return req.data;
}
export async function updateAuditRetention(data: {
auditRetentionDays: number;
}): Promise<{ retentionDays: number }> {
const req = await api.post("/audit/retention/update", data);
return req.data;
}
@@ -0,0 +1,40 @@
export type IAuditLog = {
id: string;
workspaceId: string;
actorId?: string;
actorType: string;
event: string;
resourceType: string;
resourceId?: string;
spaceId?: string;
changes?: {
before?: Record<string, any>;
after?: Record<string, any>;
};
metadata?: Record<string, any>;
ipAddress?: string;
createdAt: string;
actor?: {
id: string;
name: string;
email: string;
avatarUrl?: string;
};
resource?: {
id: string;
name: string;
slug?: string;
slugId?: string;
};
};
export type IAuditLogParams = {
event?: string;
resourceType?: string;
actorId?: string;
spaceId?: string;
startDate?: string;
endDate?: string;
cursor?: string;
limit?: number;
};
@@ -1,6 +1,7 @@
import {
useMutation,
useQueryClient,
InfiniteData,
} from "@tanstack/react-query";
import { resolveComment } from "@/features/comment/services/comment-service";
import {
@@ -10,41 +11,54 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
function updateCommentInCache(
cache: InfiniteData<IPagination<IComment>>,
commentId: string,
updater: (comment: IComment) => IComment,
): InfiniteData<IPagination<IComment>> {
return {
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.map((comment) =>
comment.id === commentId ? updater(comment) : comment,
),
})),
};
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const emit = useQueryEmit();
return useMutation({
mutationFn: (data: IResolveComment) => resolveComment(data),
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) });
const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId));
queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination<IComment>) => {
if (!old || !old.items) return old;
const updatedItems = old.items.map((comment) =>
comment.id === variables.commentId
? {
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? 'optimistic-user' : null,
resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null
}
: comment,
const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId));
const cache = previousCache as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: variables.resolved ? new Date() : null,
resolvedById: variables.resolved ? "optimistic" : null,
resolvedBy: variables.resolved
? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"])
: null,
})),
);
return {
...old,
items: updatedItems,
};
});
return { previousComments };
}
return { previousCache };
},
onError: (err, variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments);
onError: (_err, variables, context) => {
if (context?.previousCache) {
queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousCache);
}
notifications.show({
message: t("Failed to resolve comment"),
@@ -52,35 +66,26 @@ export function useResolveCommentMutation() {
});
},
onSuccess: (data: IComment, variables) => {
const pageId = data.pageId;
const currentComments = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
if (currentComments && currentComments.items) {
const updatedComments = currentComments.items.map((comment) =>
comment.id === variables.commentId
? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy }
: comment,
const cache = queryClient.getQueryData(
RQ_KEY(data.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(data.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
})),
);
queryClient.setQueryData(RQ_KEY(pageId), {
...currentComments,
items: updatedComments,
});
}
emit({
operation: "resolveComment",
pageId: pageId,
commentId: variables.commentId,
resolved: variables.resolved,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
});
queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) });
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully")
notifications.show({
message: variables.resolved
? t("Comment resolved successfully")
: t("Comment re-opened successfully"),
});
},
});
@@ -1,5 +1,6 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Container,
Title,
@@ -30,7 +31,7 @@ export function CloudLoginForm() {
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
hostname: "",
},
@@ -1,13 +1,13 @@
import React, { useState } from "react";
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
@@ -34,7 +34,7 @@ export function LdapLoginModal({
const [error, setError] = useState<string | null>(null);
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
username: "",
password: "",
@@ -59,13 +59,13 @@ export function LdapLoginModal({
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
onClose();
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err: any) {
setIsLoading(false);
@@ -1,16 +1,17 @@
import { Button, Group, Text, Modal, TextInput } from "@mantine/core";
import * as z from "zod";
import { z } from "zod/v4";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { getSubdomainHost } from "@/lib/config.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { getHostnameUrl } from "@/ee/utils.ts";
import { useAtom } from "jotai/index";
import { useAtom } from "jotai";
import {
currentUserAtom,
workspaceAtom,
@@ -66,7 +67,7 @@ function ChangeHostnameForm({ onClose }: ChangeHostnameFormProps) {
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
hostname: currentUser?.workspace?.hostname,
},
@@ -0,0 +1,12 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
@@ -1,7 +1,8 @@
import * as z from "zod";
import { z } from "zod/v4";
import React from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useTranslation } from "react-i18next";
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
import { useDisclosure } from "@mantine/hooks";
@@ -49,7 +50,7 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const activateLicenseMutation = useActivateMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
licenseKey: "",
},
@@ -1,39 +1,76 @@
import { Group, Table, ThemeIcon } from "@mantine/core";
import { Group, List, Stack, Table, Text, ThemeIcon } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
const enterpriseFeatures = [
"SSO (SAML, OIDC, LDAP)",
"AI Integration (Search & Assistant)",
"Page-level Permissions",
"Audit Logs",
"API Keys",
"MCP Support",
"Multi-factor Authentication (2FA)",
"Enterprise Controls",
"Advanced Search Engine Support",
"Full-text Search in Attachments (PDF, DOCX)",
"Resolve Comments",
"Confluence Import",
"DOCX Import",
];
export default function OssDetails() {
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
<Group wrap="nowrap">
Open Source
<div>
<ThemeIcon
color="green"
variant="light"
size={24}
radius="xl"
>
<IconCheck size={16} />
</ThemeIcon>
</div>
</Group>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Stack gap="lg">
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
<Group wrap="nowrap">
Open Source
<div>
<ThemeIcon
color="green"
variant="light"
size={24}
radius="xl"
>
<IconCheck size={16} />
</ThemeIcon>
</div>
</Group>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Stack gap="md">
<Text fw={500}>Upgrade to the Enterprise Edition to unlock:</Text>
<List
spacing={4}
size="sm"
icon={
<ThemeIcon size={20} color={"gray"} radius="xl">
<IconCheck size={14} />
</ThemeIcon>
}
>
{enterpriseFeatures.map((feature) => (
<List.Item key={feature}>{feature}</List.Item>
))}
</List>
<Text size="sm" c="dimmed">
Contact <a href="mailto:sales@docmost.com?subject=Enterprise%20License%20Inquiry">sales@docmost.com </a> to purchase an enterprise license.
</Text>
</Stack>
</Stack>
);
}
@@ -8,10 +8,10 @@ import {
Group,
List,
Code,
CopyButton,
Alert,
PasswordInput,
} from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import {
IconRefresh,
IconCopy,
@@ -23,8 +23,8 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaBackupCodesModalProps {
@@ -51,7 +51,7 @@ export function MfaBackupCodesModal({
});
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
confirmPassword: "",
},
@@ -12,15 +12,15 @@ import {
ThemeIcon,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { IconDeviceMobile, IconLock } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { notifications } from "@mantine/notifications";
import classes from "./mfa-challenge.module.css";
import { verifyMfa } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { useTranslation } from "react-i18next";
import * as z from "zod";
import { z } from "zod/v4";
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
const formSchema = z.object({
@@ -43,7 +43,7 @@ export function MfaChallenge() {
const [useBackupCode, setUseBackupCode] = useState(false);
const form = useForm<MfaChallengeFormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
code: "",
},
@@ -53,7 +53,7 @@ export function MfaChallenge() {
setIsLoading(true);
try {
await verifyMfa(values.code);
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} catch (error: any) {
setIsLoading(false);
notifications.show({
@@ -9,11 +9,11 @@ import {
} from "@mantine/core";
import { IconShieldOff, IconAlertTriangle } from "@tabler/icons-react";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { z } from "zod/v4";
import { disableMfa } from "@/ee/mfa";
import useCurrentUser from "@/features/user/hooks/use-current-user";
@@ -41,7 +41,7 @@ export function MfaDisableModal({
});
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
confirmPassword: "",
},
@@ -63,7 +63,7 @@ export function MfaDisableModal({
const handleSubmit = async (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
@@ -11,7 +11,6 @@ import {
PinInput,
Alert,
List,
CopyButton,
ActionIcon,
Tooltip,
Paper,
@@ -20,6 +19,7 @@ import {
Collapse,
UnstyledButton,
} from "@mantine/core";
import { CopyButton } from "@/components/common/copy-button";
import {
IconQrcode,
IconShieldCheck,
@@ -36,8 +36,8 @@ import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { setupMfa, enableMfa } from "@/ee/mfa";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
interface MfaSetupModalProps {
opened: boolean;
@@ -71,7 +71,7 @@ export function MfaSetupModal({
const [manualEntryOpen, setManualEntryOpen] = useState(false);
const form = useForm({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
verificationCode: "",
},
@@ -3,7 +3,7 @@ import { Container, Paper, Title, Text, Alert, Stack } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export default function MfaSetupRequired() {
@@ -11,7 +11,7 @@ export default function MfaSetupRequired() {
const navigate = useNavigate();
const handleSetupComplete = () => {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
};
return (
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { validateMfaAccess } from "@/ee/mfa";
export function useMfaPageProtection() {
@@ -13,8 +13,10 @@ export function useMfaPageProtection() {
const checkAccess = async () => {
const result = await validateMfaAccess();
const search = location.search;
if (!result.valid) {
navigate(APP_ROUTE.AUTH.LOGIN);
navigate(APP_ROUTE.AUTH.LOGIN + search);
return;
}
@@ -26,17 +28,17 @@ export function useMfaPageProtection() {
if (result.requiresMfaSetup && !isOnSetupPage) {
// User needs to set up MFA but is on challenge page
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + search);
} else if (
!result.requiresMfaSetup &&
result.userHasMfa &&
!isOnChallengePage
) {
// User has MFA and should be on challenge page
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + search);
} else if (!result.isTransferToken) {
// User has a regular auth token, shouldn't be on MFA pages
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
} else {
setIsValid(true);
}
@@ -0,0 +1,112 @@
import { Group, Menu, Text, UnstyledButton } from "@mantine/core";
import {
IconChevronDown,
IconLock,
IconShieldLock,
IconCheck,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "./page-permission.module.css";
type AccessLevel = "open" | "restricted";
type GeneralAccessSelectProps = {
value: AccessLevel;
onChange: (value: AccessLevel) => void;
disabled?: boolean;
hasInheritedRestriction?: boolean;
};
export function GeneralAccessSelect({
value,
onChange,
disabled,
hasInheritedRestriction,
}: GeneralAccessSelectProps) {
const { t } = useTranslation();
const isDirectlyRestricted = value === "restricted";
const showInheritedState = hasInheritedRestriction && !isDirectlyRestricted;
const currentLabel = showInheritedState
? t("Restricted by parent")
: isDirectlyRestricted
? t("Restricted")
: t("Open");
const currentDescription = showInheritedState
? t("Inherits restrictions from ancestor page")
: isDirectlyRestricted
? t("Only people listed below can access this page")
: t("Everyone in this space can access");
const CurrentIcon = showInheritedState
? IconShieldLock
: isDirectlyRestricted
? IconLock
: IconShieldLock;
const accessOptions = [
{
value: "open" as const,
label: hasInheritedRestriction ? t("Restricted by parent") : t("Open"),
description: hasInheritedRestriction
? t("Use only inherited restrictions")
: t("No additional restrictions on this page"),
icon: IconShieldLock,
},
{
value: "restricted" as const,
label: t("Restricted"),
description: hasInheritedRestriction
? t("Add restrictions on top of inherited")
: t("Only specific people can access"),
icon: IconLock,
},
];
return (
<Menu withArrow disabled={disabled}>
<Menu.Target>
<UnstyledButton className={classes.generalAccessBox} disabled={disabled}>
<div
className={`${classes.generalAccessIcon} ${isDirectlyRestricted || showInheritedState ? classes.generalAccessIconRestricted : ""}`}
>
<CurrentIcon size={18} stroke={1.5} />
</div>
<div style={{ flex: 1 }}>
<Group gap={4}>
<Text size="sm" fw={500}>
{currentLabel}
</Text>
{!disabled && <IconChevronDown size={14} />}
</Group>
<Text size="xs" c="dimmed">
{currentDescription}
</Text>
</div>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{accessOptions.map((option) => (
<Menu.Item
key={option.value}
onClick={() => onChange(option.value)}
leftSection={<option.icon size={16} stroke={1.5} />}
rightSection={
option.value === value ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{option.label}</Text>
<Text size="xs" c="dimmed">
{option.description}
</Text>
</div>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,107 @@
import { Menu, Text, UnstyledButton, Group } from "@mantine/core";
import { IconChevronDown, IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text";
import { IconGroupCircle } from "@/components/icons/icon-people-circle";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { formatMemberCount } from "@/lib";
import {
IPagePermissionMember,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
pagePermissionRoleData,
getPagePermissionRoleLabel,
} from "@/ee/page-permission/types/page-permission-role-data";
import classes from "./page-permission.module.css";
type PagePermissionItemProps = {
member: IPagePermissionMember;
onRoleChange: (memberId: string, type: "user" | "group", role: string) => void;
onRemove: (memberId: string, type: "user" | "group") => void;
disabled?: boolean;
};
export function PagePermissionItem({
member,
onRoleChange,
onRemove,
disabled,
}: PagePermissionItemProps) {
const { t } = useTranslation();
const currentUser = useAtomValue(userAtom);
const isCurrentUser = member.type === "user" && member.id === currentUser?.id;
const roleLabel = getPagePermissionRoleLabel(member.role);
return (
<div className={classes.permissionItem}>
<div className={classes.permissionItemInfo}>
{member.type === "user" && (
<CustomAvatar avatarUrl={member.avatarUrl} name={member.name} />
)}
{member.type === "group" && <IconGroupCircle />}
<div className={classes.permissionItemDetails}>
<AutoTooltipText
fz="sm"
fw={500}
tooltipLabel={isCurrentUser ? `${member.name} (${t("You")})` : member.name}
>
{member.name}
{isCurrentUser && <Text span c="dimmed"> ({t("You")})</Text>}
</AutoTooltipText>
<AutoTooltipText fz="xs" c="dimmed">
{member.type === "user" ? member.email : formatMemberCount(member.memberCount, t)}
</AutoTooltipText>
</div>
</div>
<div className={classes.permissionItemRole}>
{isCurrentUser || disabled ? (
<Text size="sm" c="dimmed">
{t(roleLabel)}
</Text>
) : (
<Menu withArrow position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap={4}>
<Text size="sm">{t(roleLabel)}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{pagePermissionRoleData.map((role) => (
<Menu.Item
key={role.value}
onClick={() => onRoleChange(member.id, member.type, role.value)}
rightSection={
role.value === member.role ? <IconCheck size={16} /> : null
}
>
<div>
<Text size="sm">{t(role.label)}</Text>
<Text size="xs" c="dimmed">
{t(role.description)}
</Text>
</div>
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
color="red"
onClick={() => onRemove(member.id, member.type)}
>
{t("Remove access")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import { Center, Group, Loader, ScrollArea, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import { useEffect, useRef } from "react";
import { modals } from "@mantine/modals";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { PagePermissionRole } from "@/ee/page-permission/types/page-permission.types";
import {
usePagePermissionsQuery,
useRemovePagePermissionMutation,
useUpdatePagePermissionRoleMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionItem } from "@/ee/page-permission";
import classes from "./page-permission.module.css";
type PagePermissionListProps = {
pageId: string;
canManage: boolean;
onRemoveAll?: () => void;
};
export function PagePermissionList({
pageId,
canManage,
onRemoveAll,
}: PagePermissionListProps) {
const { t } = useTranslation();
const currentUser = useAtomValue(userAtom);
const updateRoleMutation = useUpdatePagePermissionRoleMutation();
const removeMutation = useRemovePagePermissionMutation();
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
usePagePermissionsQuery(pageId);
const sentinelRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ root: viewportRef.current, threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleRoleChange = async (
memberId: string,
type: "user" | "group",
newRole: string,
) => {
await updateRoleMutation.mutateAsync({
pageId,
role: newRole as PagePermissionRole,
...(type === "user" ? { userId: memberId } : { groupId: memberId }),
});
};
const handleRemove = (memberId: string, type: "user" | "group") => {
modals.openConfirmModal({
title: t("Remove access"),
children: (
<Text size="sm">
{t(
"Are you sure you want to remove this member's access to the page?",
)}
</Text>
),
centered: true,
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: async () => {
await removeMutation.mutateAsync({
pageId,
...(type === "user"
? { userIds: [memberId] }
: { groupIds: [memberId] }),
});
},
});
};
const handleRemoveAll = () => {
modals.openConfirmModal({
title: t("Remove all access"),
children: (
<Text size="sm">
{t(
"Are you sure you want to remove all specific access? This will make the page open to everyone in the space.",
)}
</Text>
),
centered: true,
labels: { confirm: t("Remove all"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemoveAll?.(),
});
};
const members = data?.pages.flatMap((page) => page.items) ?? [];
const sortedMembers = [...members].sort((a, b) => {
if (a.type === "user" && a.id === currentUser?.id) return -1;
if (b.type === "user" && b.id === currentUser?.id) return 1;
if (a.type === "group" && b.type === "user") return -1;
if (a.type === "user" && b.type === "group") return 1;
return 0;
});
if (isLoading) {
return (
<Center py="md">
<Loader size="sm" />
</Center>
);
}
if (members.length === 0) {
return null;
}
return (
<>
<Group justify="space-between" align="center">
<Text size="sm" fw={500}>
{t("People with access")}
</Text>
{canManage && members.length > 0 && (
<Text className={classes.removeAllLink} onClick={handleRemoveAll}>
{t("Remove all")}
</Text>
)}
</Group>
<ScrollArea mah={250} viewportRef={viewportRef}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
member={member}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
disabled={!canManage}
/>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</ScrollArea>
</>
);
}
@@ -0,0 +1,189 @@
import { useState } from "react";
import {
Box,
Button,
Divider,
Group,
Paper,
Select,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
import { IconArrowRight, IconLock, IconShieldLock } from "@tabler/icons-react";
import { MultiMemberSelect } from "@/features/space/components/multi-member-select";
import {
IPageRestrictionInfo,
PagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
useAddPagePermissionMutation,
useRestrictPageMutation,
useUnrestrictPageMutation,
} from "@/ee/page-permission/queries/page-permission-query";
import { pagePermissionRoleData } from "@/ee/page-permission/types/page-permission-role-data";
import { GeneralAccessSelect } from "@/ee/page-permission";
import { PagePermissionList } from "@/ee/page-permission";
import classes from "./page-permission.module.css";
import { buildPageUrl } from "@/features/page/page.utils";
type PagePermissionTabProps = {
pageId: string;
restrictionInfo: IPageRestrictionInfo;
};
export function PagePermissionTab({
pageId,
restrictionInfo,
}: PagePermissionTabProps) {
const { t } = useTranslation();
const { spaceSlug } = useParams();
const [memberIds, setMemberIds] = useState<string[]>([]);
const [role, setRole] = useState<string>(PagePermissionRole.WRITER);
const restrictMutation = useRestrictPageMutation();
const unrestrictMutation = useUnrestrictPageMutation();
const addPermissionMutation = useAddPagePermissionMutation();
const hasInheritedRestriction = restrictionInfo.hasInheritedRestriction;
const hasDirectRestriction = restrictionInfo.hasDirectRestriction;
const canManage = restrictionInfo.userAccess.canManage;
const handleDirectAccessChange = async (value: "open" | "restricted") => {
if (value === "restricted" && !hasDirectRestriction) {
await restrictMutation.mutateAsync(pageId);
} else if (value === "open" && hasDirectRestriction) {
await unrestrictMutation.mutateAsync(pageId);
}
};
const handleAddMembers = async () => {
if (memberIds.length === 0) return;
const userIds = memberIds
.filter((id) => id.startsWith("user-"))
.map((id) => id.replace("user-", ""));
const groupIds = memberIds
.filter((id) => id.startsWith("group-"))
.map((id) => id.replace("group-", ""));
await addPermissionMutation.mutateAsync({
pageId,
role: role as PagePermissionRole,
...(userIds.length > 0 && { userIds }),
...(groupIds.length > 0 && { groupIds }),
});
setMemberIds([]);
};
const handleRemoveAll = async () => {
await unrestrictMutation.mutateAsync(pageId);
};
return (
<Stack gap="md">
{hasInheritedRestriction && (
<Paper className={classes.inheritedSection} p="sm" radius="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size="lg"
radius="sm"
variant="light"
color="orange"
>
<IconShieldLock size={18} stroke={1.5} />
</ThemeIcon>
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("Inherited restriction")}
</Text>
<Group gap={4}>
<Text size="xs" c="dimmed">
{t("Access limited by")}
</Text>
{restrictionInfo.inheritedFrom && (
<Link
to={buildPageUrl(
spaceSlug,
restrictionInfo.inheritedFrom.slugId,
restrictionInfo.inheritedFrom.title,
)}
style={{ textDecoration: "none" }}
>
<Group gap={2}>
<Text size="xs" fw={500} c="blue">
{restrictionInfo.inheritedFrom.title || t("Untitled")}
</Text>
<IconArrowRight size={12} color="var(--mantine-color-blue-6)" />
</Group>
</Link>
)}
</Group>
</Box>
</Group>
</Paper>
)}
<Box>
<GeneralAccessSelect
value={hasDirectRestriction ? "restricted" : "open"}
onChange={handleDirectAccessChange}
disabled={!canManage}
hasInheritedRestriction={hasInheritedRestriction}
/>
{!hasDirectRestriction && !hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Restrict access to control who can view and edit this page")}
</Text>
)}
{!hasDirectRestriction && hasInheritedRestriction && (
<Text size="xs" c="dimmed" mt={4}>
{t("Add additional restrictions specific to this page")}
</Text>
)}
</Box>
{hasDirectRestriction && (
<>
<Divider />
{canManage && (
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<MultiMemberSelect value={memberIds} onChange={setMemberIds} />
</Box>
<Select
data={pagePermissionRoleData.map((r) => ({
label: t(r.label),
value: r.value,
}))}
value={role}
onChange={(value) => value && setRole(value)}
allowDeselect={false}
variant="filled"
w={120}
/>
<Button
onClick={handleAddMembers}
disabled={memberIds.length === 0}
loading={addPermissionMutation.isPending}
>
{t("Add")}
</Button>
</Group>
)}
<PagePermissionList
pageId={pageId}
canManage={canManage}
onRemoveAll={handleRemoveAll}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,128 @@
.generalAccessBox {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) 0;
}
.generalAccessIcon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--mantine-radius-sm);
@mixin light {
background-color: var(--mantine-color-gray-1);
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.generalAccessIconRestricted {
@mixin light {
background-color: var(--mantine-color-red-0);
color: var(--mantine-color-red-6);
}
@mixin dark {
background-color: rgba(250, 82, 82, 0.1);
color: var(--mantine-color-red-5);
}
}
.permissionItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--mantine-spacing-xs) 0;
gap: var(--mantine-spacing-sm);
}
.permissionItemInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
flex: 1;
min-width: 0;
overflow: hidden;
}
.permissionItemDetails {
min-width: 0;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.permissionItemRole {
flex-shrink: 0;
}
.avatarStack {
display: flex;
align-items: center;
}
.avatarStackItem {
margin-left: -8px;
border: 2px solid var(--mantine-color-body);
border-radius: 50%;
}
.avatarStackItem:first-child {
margin-left: 0;
}
.specificAccessHeader {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
margin-top: var(--mantine-spacing-md);
margin-bottom: var(--mantine-spacing-xs);
}
.removeAllLink {
cursor: pointer;
font-size: var(--mantine-font-size-sm);
@mixin light {
color: var(--mantine-color-gray-6);
}
@mixin dark {
color: var(--mantine-color-dark-2);
}
&:hover {
text-decoration: underline;
}
}
.inheritedInfo {
display: flex;
align-items: center;
gap: var(--mantine-spacing-xs);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border-radius: var(--mantine-radius-sm);
margin-bottom: var(--mantine-spacing-sm);
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}
.inheritedSection {
@mixin light {
background-color: var(--mantine-color-orange-0);
border: 1px solid var(--mantine-color-orange-2);
}
@mixin dark {
background-color: rgba(255, 146, 43, 0.08);
border: 1px solid rgba(255, 146, 43, 0.2);
}
}
@@ -0,0 +1,132 @@
import { useState } from "react";
import {
Button,
Indicator,
Loader,
Modal,
Stack,
Tabs,
Text,
Center,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconWorld, IconLock } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
import { PagePermissionTab } from "@/ee/page-permission";
import { PublishTab } from "./publish-tab";
import { useShareForPageQuery } from "@/features/share/queries/share-query";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { useSpaceQuery } from "@/features/space/queries/space-query";
type PageShareModalProps = {
readOnly?: boolean;
};
export function PageShareModal({ readOnly }: PageShareModalProps) {
const { t } = useTranslation();
const { pageSlug, spaceSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false);
const isCloudEE = useIsCloudEE();
const [activeTab, setActiveTab] = useState<string | null>(
isCloudEE ? "access" : "publish",
);
const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug);
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
const spaceSharingDisabled = space?.settings?.sharing?.disabled === true;
const { data: page } = usePageQuery({ pageId: pageSlugId });
const pageId = page?.id;
const isRestricted = page?.permissions?.hasRestriction ?? false;
const { data: share } = useShareForPageQuery(pageId);
const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
return (
<>
<Button
style={{ border: "none" }}
size="compact-sm"
leftSection={
isRestricted ? (
<Indicator color="red" offset={5} withBorder>
<IconLock size={20} stroke={1.5} />
</Indicator>
) : isPubliclyShared ? (
<Indicator color="green" offset={5} withBorder>
<IconWorld size={20} stroke={1.5} />
</Indicator>
) : null
}
variant="default"
onClick={open}
>
{t("Share")}
</Button>
<Modal opened={opened} onClose={close} title={t("Share")} size={600}>
<Tabs value={activeTab} color="dark" onChange={setActiveTab}>
<Tabs.List mb="md">
<Tabs.Tab value="access">{t("Access")}</Tabs.Tab>
<Tabs.Tab
value="publish"
rightSection={
isPubliclyShared ? (
<Indicator color="green" size={8} processing />
) : null
}
>
{t("Publish")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="access">
{!isCloudEE ? (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Page permissions")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Control who can view and edit individual pages. Available with an enterprise license.",
)}
</Text>
</Stack>
) : restrictionLoading || !pageId || !restrictionInfo ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<PagePermissionTab
pageId={pageId}
restrictionInfo={restrictionInfo}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="publish">
<PublishTab
pageId={pageId}
readOnly={readOnly}
isRestricted={isRestricted}
workspaceSharingDisabled={workspaceSharingDisabled}
spaceSharingDisabled={spaceSharingDisabled}
/>
</Tabs.Panel>
</Tabs>
</Modal>
</>
);
}
@@ -0,0 +1,254 @@
import { useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Anchor,
Button,
Group,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink, IconLock } from "@tabler/icons-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { getPageIcon } from "@/lib";
import CopyTextButton from "@/components/common/copy";
import { getAppUrl, isCloud } from "@/lib/config";
import { buildPageUrl } from "@/features/page/page.utils";
import {
useCreateShareMutation,
useDeleteShareMutation,
useShareForPageQuery,
useUpdateShareMutation,
} from "@/features/share/queries/share-query";
import useTrial from "@/ee/hooks/use-trial";
type PublishTabProps = {
pageId: string;
readOnly?: boolean;
isRestricted?: boolean;
workspaceSharingDisabled?: boolean;
spaceSharingDisabled?: boolean;
};
export function PublishTab({ pageId, readOnly, isRestricted, workspaceSharingDisabled, spaceSharingDisabled }: PublishTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { pageSlug, spaceSlug } = useParams();
const { isTrial } = useTrial();
const { data: share } = useShareForPageQuery(pageId);
const createShareMutation = useCreateShareMutation();
const updateShareMutation = useUpdateShareMutation();
const deleteShareMutation = useDeleteShareMutation();
const pageIsShared = share && share.level === 0;
const isDescendantShared = share && share.level > 0;
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
useEffect(() => {
setIsPagePublic(!!share);
}, [share, pageId]);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
if (value) {
createShareMutation.mutateAsync({
pageId: pageId,
includeSubPages: true,
searchIndexing: false,
});
setIsPagePublic(value);
} else {
if (share && share.id) {
deleteShareMutation.mutateAsync(share.id);
setIsPagePublic(value);
}
}
};
const handleSubPagesChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
includeSubPages: value,
});
};
const handleIndexSearchChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.checked;
updateShareMutation.mutateAsync({
shareId: share.id,
searchIndexing: value,
});
};
const shareLink = useMemo(
() => (
<Group my="sm" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={publicLink}
readOnly
rightSection={<CopyTextButton text={publicLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={publicLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
),
[publicLink],
);
if (isCloud() && isTrial) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Upgrade to share pages")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t(
"Page sharing is available on paid plans. Upgrade to share your pages publicly.",
)}
</Text>
<Button size="xs" onClick={() => navigate("/settings/billing")}>
{t("Upgrade Plan")}
</Button>
</Stack>
);
}
if (workspaceSharingDisabled || spaceSharingDisabled) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Public sharing is disabled")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{workspaceSharingDisabled
? t("Public sharing has been disabled at the workspace level.")
: t("Public sharing has been disabled for this space.")}
</Text>
</Stack>
);
}
if (isRestricted) {
return (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
{t("Restricted page")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{t("Restricted pages cannot be shared publicly.")}
</Text>
</Stack>
);
}
if (isDescendantShared) {
return (
<Stack gap="sm">
<Text size="sm">{t("Inherits public sharing from")}</Text>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={buildPageUrl(
spaceSlug,
share.sharedPage.slugId,
share.sharedPage.title,
)}
>
<Group gap="4" wrap="nowrap">
{getPageIcon(share.sharedPage.icon)}
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
</Text>
</Group>
</Anchor>
{shareLink}
</Stack>
);
}
return (
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">
{isPagePublic ? t("Shared to web") : t("Share to web")}
</Text>
<Text size="xs" c="dimmed">
{isPagePublic
? t("Anyone with the link can view this page")
: t("Make this page publicly accessible")}
</Text>
</div>
<Switch
onChange={handleChange}
checked={isPagePublic}
disabled={readOnly}
size="xs"
/>
</Group>
{pageIsShared && (
<>
{shareLink}
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Include sub-pages")}</Text>
<Text size="xs" c="dimmed">
{t("Make sub-pages public too")}
</Text>
</div>
<Switch
onChange={handleSubPagesChange}
checked={share.includeSubPages}
size="xs"
disabled={readOnly}
/>
</Group>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="sm">{t("Search engine indexing")}</Text>
<Text size="xs" c="dimmed">
{t("Allow search engines to index page")}
</Text>
</div>
<Switch
onChange={handleIndexSearchChange}
checked={share.searchIndexing}
size="xs"
disabled={readOnly}
/>
</Group>
</>
)}
</Stack>
);
}
@@ -0,0 +1,26 @@
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type";
import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-permission-query";
export function usePagePermission(pageId: string, spaceRules: any) {
const spaceAbility = useSpaceAbility(spaceRules);
const { data: restrictionInfo, isLoading } =
usePageRestrictionInfoQuery(pageId);
if (isLoading || !restrictionInfo) {
return { canEdit: false, restrictionInfo: undefined };
}
const hasRestriction =
restrictionInfo.hasDirectRestriction ||
restrictionInfo.hasInheritedRestriction;
const canEdit = hasRestriction
? (restrictionInfo.userAccess?.canEdit ?? false)
: spaceAbility.can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
return { canEdit, restrictionInfo };
}
@@ -0,0 +1,11 @@
export * from "./components/page-share-modal";
export * from "./components/page-permission-tab";
export * from "./components/publish-tab";
export * from "./components/page-permission-list";
export * from "./components/page-permission-item";
export * from "./components/general-access-select";
export * from "./hooks/use-page-permission";
export * from "./queries/page-permission-query";
export * from "./services/page-permission-service";
export * from "./types/page-permission.types";
export * from "./types/page-permission-role-data";
@@ -0,0 +1,175 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
IAddPagePermission,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
import {
addPagePermission,
getPagePermissions,
getPageRestrictionInfo,
removePagePermission,
restrictPage,
unrestrictPage,
updatePagePermissionRole,
} from "@/ee/page-permission/services/page-permission-service";
import { IPage } from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function usePageRestrictionInfoQuery(
pageId: string | undefined,
): UseQueryResult<IPageRestrictionInfo, Error> {
return useQuery({
queryKey: ["page-restriction-info", pageId],
queryFn: () => getPageRestrictionInfo(pageId),
enabled: !!pageId,
});
}
export function usePagePermissionsQuery(pageId: string) {
return useInfiniteQuery({
queryKey: ["page-permissions", pageId],
queryFn: ({ pageParam }) => getPagePermissions(pageId, pageParam),
enabled: !!pageId,
//gcTime: 5000,
placeholderData: keepPreviousData,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
function updatePageRestrictionCache(
queryClient: ReturnType<typeof useQueryClient>,
pageId: string,
hasRestriction: boolean,
) {
queryClient.setQueriesData<IPage>(
{ queryKey: ["pages"] },
(old) => {
if (old?.id === pageId) {
return {
...old,
permissions: { ...old.permissions, hasRestriction },
};
}
return old;
},
);
queryClient.invalidateQueries({
queryKey: ["page-restriction-info", pageId],
});
queryClient.removeQueries({
queryKey: ["page-permissions", pageId],
});
}
export function useRestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => restrictPage(pageId),
onSuccess: (_, pageId) => {
updatePageRestrictionCache(queryClient, pageId, true);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to restrict page"),
color: "red",
});
},
});
}
export function useUnrestrictPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (pageId) => unrestrictPage(pageId),
onSuccess: (_, pageId) => {
updatePageRestrictionCache(queryClient, pageId, false);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove page restriction"),
color: "red",
});
},
});
}
export function useAddPagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IAddPagePermission>({
mutationFn: (data) => addPagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to add permission"),
color: "red",
});
},
});
}
export function useRemovePagePermissionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRemovePagePermission>({
mutationFn: (data) => removePagePermission(data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to remove permission"),
color: "red",
});
},
});
}
export function useUpdatePagePermissionRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdatePagePermissionRole>({
mutationFn: (data) => updatePagePermissionRole(data),
onSuccess: (_, variables) => {
queryClient.refetchQueries({
queryKey: ["page-permissions", variables.pageId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update permission"),
color: "red",
});
},
});
}
@@ -0,0 +1,55 @@
import api from "@/lib/api-client";
import { IPagination } from "@/lib/types";
import {
IAddPagePermission,
IPagePermissionMember,
IPageRestrictionInfo,
IRemovePagePermission,
IUpdatePagePermissionRole,
} from "@/ee/page-permission/types/page-permission.types";
export async function restrictPage(pageId: string): Promise<void> {
await api.post("/pages/restrict", { pageId });
}
export async function addPagePermission(
data: IAddPagePermission,
): Promise<void> {
await api.post("/pages/add-permission", data);
}
export async function removePagePermission(
data: IRemovePagePermission,
): Promise<void> {
await api.post("/pages/remove-permission", data);
}
export async function updatePagePermissionRole(
data: IUpdatePagePermissionRole,
): Promise<void> {
await api.post("/pages/update-permission", data);
}
export async function unrestrictPage(pageId: string): Promise<void> {
await api.post("/pages/remove-restriction", { pageId });
}
export async function getPagePermissions(
pageId: string,
cursor?: string,
): Promise<IPagination<IPagePermissionMember>> {
const req = await api.post<IPagination<IPagePermissionMember>>(
"/pages/permissions",
{ pageId, ...(cursor && { cursor }) },
);
return req.data;
}
export async function getPageRestrictionInfo(
pageId: string,
): Promise<IPageRestrictionInfo> {
const req = await api.post<IPageRestrictionInfo>("/pages/permission-info", {
pageId,
});
return req.data;
}
@@ -0,0 +1,20 @@
import { IRoleData } from "@/lib/types";
import { PagePermissionRole } from "./page-permission.types";
export const pagePermissionRoleData: IRoleData[] = [
{
label: "Can edit",
value: PagePermissionRole.WRITER,
description: "Can edit page and manage access",
},
{
label: "Can view",
value: PagePermissionRole.READER,
description: "Can only view page",
},
];
export function getPagePermissionRoleLabel(value: string): string | undefined {
const role = pagePermissionRoleData.find((item) => item.value === value);
return role ? role.label : undefined;
}
@@ -0,0 +1,61 @@
export enum PagePermissionRole {
READER = "reader",
WRITER = "writer",
}
export type IAddPagePermission = {
pageId: string;
role: PagePermissionRole;
userIds?: string[];
groupIds?: string[];
};
export type IRemovePagePermission = {
pageId: string;
userIds?: string[];
groupIds?: string[];
};
export type IUpdatePagePermissionRole = {
pageId: string;
role: PagePermissionRole;
userId?: string;
groupId?: string;
};
export type IPageRestrictionInfo = {
restrictionId?: string;
hasDirectRestriction: boolean;
hasInheritedRestriction: boolean;
inheritedFrom?: {
id: string;
slugId: string;
title: string;
};
userAccess: {
canView: boolean;
canEdit: boolean;
canManage: boolean;
};
};
type IPagePermissionBase = {
id: string;
name: string;
role: string;
createdAt: string;
};
export type IPagePermissionUser = IPagePermissionBase & {
type: "user";
email: string;
avatarUrl: string | null;
};
export type IPagePermissionGroup = IPagePermissionBase & {
type: "group";
memberCount: number;
isDefault: boolean;
};
export type IPagePermissionMember = IPagePermissionUser | IPagePermissionGroup;
@@ -1,7 +1,7 @@
import { useAtom } from "jotai";
import * as z from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core";
@@ -22,7 +22,7 @@ export default function AllowedDomains() {
const [, setDomains] = useState<string[]>([]);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
emailDomains: workspace?.emailDomains || [],
},
@@ -0,0 +1,88 @@
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,23 +10,18 @@ export default function EnforceMfa() {
const { t } = useTranslation();
return (
<>
<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>
<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>
);
}
@@ -0,0 +1,84 @@
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>
);
}
@@ -1,7 +1,7 @@
import React from "react";
import { z } from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
@@ -30,7 +30,7 @@ export function SsoGoogleForm({ provider, onClose }: SsoFormProps) {
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
@@ -1,7 +1,7 @@
import React from "react";
import { z } from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
@@ -59,7 +59,7 @@ export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
@@ -1,6 +1,7 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import { buildCallbackUrl } from "@/ee/security/sso.utils.ts";
import classes from "@/ee/security/components/sso.module.css";
@@ -39,7 +40,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
@@ -43,7 +43,7 @@ export default function SsoProviderList() {
return null;
}
if (data?.length === 0) {
if (data?.items.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
}
@@ -81,7 +81,7 @@ export default function SsoProviderList() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data
{data?.items
.sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff;
@@ -104,7 +104,11 @@ export default function SsoProviderList() {
</Group>
</Table.Td>
<Table.Td>
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
<Badge
color={"gray"}
variant="light"
style={{ whiteSpace: "nowrap" }}
>
{provider.type.toUpperCase()}
</Badge>
</Table.Td>
@@ -134,41 +138,41 @@ export default function SsoProviderList() {
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
</ActionIcon>
<Menu
transitionProps={{ transition: "pop" }}
withArrow
position="bottom-end"
withinPortal
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />}
color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
</ActionIcon>
<Menu
transitionProps={{ transition: "pop" }}
withArrow
position="bottom-end"
withinPortal
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />}
color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
@@ -1,7 +1,7 @@
import React from "react";
import { z } from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
@@ -49,7 +49,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
validate: zod4Resolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
@@ -0,0 +1,138 @@
import { useState, useEffect } from "react";
import {
Group,
Text,
NumberInput,
Select,
Button,
Tooltip,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
type RetentionUnit = "days" | "months" | "years";
const DEFAULT_RETENTION_DAYS = 30;
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
if (days >= 30 && days % 30 === 0) {
return { amount: days / 30, unit: "months" };
}
return { amount: days, unit: "days" };
}
function retentionToDays(amount: number, unit: RetentionUnit): number {
if (unit === "years") return amount * 365;
if (unit === "months") return amount * 30;
return amount;
}
export default function TrashRetention() {
const { t } = useTranslation();
const hasAccess = useEnterpriseAccess();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const currentDays = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const parsed = daysToRetention(currentDays);
const [retentionAmount, setRetentionAmount] = useState<number | string>(parsed.amount);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(parsed.unit);
const [saving, setSaving] = useState(false);
useEffect(() => {
const days = workspace?.trashRetentionDays ?? DEFAULT_RETENTION_DAYS;
const { amount, unit } = daysToRetention(days);
setRetentionAmount(amount);
setRetentionUnit(unit);
}, [workspace?.trashRetentionDays]);
const handleSave = async () => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days === currentDays) return;
setSaving(true);
try {
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
setWorkspace(updatedWorkspace);
notifications.show({
message: t("Trash retention updated"),
});
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message || t("Failed to update trash retention"),
color: "red",
});
const { amount, unit } = daysToRetention(currentDays);
setRetentionAmount(amount);
setRetentionUnit(unit);
} finally {
setSaving(false);
}
};
const isDirty = retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit,
) !== currentDays;
return (
<div>
<Text size="md">{t("Trash retention")}</Text>
<Text size="sm" c="dimmed" mb="sm">
{t("Pages in trash will be permanently deleted after this period.")}
</Text>
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
>
<Group gap="xs" wrap="nowrap" maw={320}>
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
disabled={!hasAccess}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
disabled={!hasAccess}
/>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!hasAccess || !isDirty}
>
{t("Save")}
</Button>
</Group>
</Tooltip>
</div>
);
}
+34 -10
View File
@@ -9,15 +9,17 @@ 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 TrashRetention from "@/ee/security/components/trash-retention.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 { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
const hasEnterpriseAccess = useEnterpriseAccess();
const isCloudEE = useIsCloudEE();
if (!isAdmin) {
return null;
@@ -30,26 +32,48 @@ export default function Security() {
</Helmet>
<SettingsTitle title={t("Security")} />
<AllowedDomains />
<Divider my="lg" />
<EnforceMfa />
<Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && (
<>
<DisablePublicSharing />
<Divider my="lg" />
</>
)}
{!isCloud() && (
<>
<TrashRetention />
<Divider my="lg" />
</>
)}
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
{hasEnterpriseAccess && (
<>
<EnforceSso />
<Divider my="lg" />
</>
)}
{isCloudEE && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
<>
<CreateSsoProvider />
<Divider size={0} my="lg" />
</>
) : null}
)}
<SsoProviderList />
</>
@@ -13,8 +13,9 @@ import {
} from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
export function useGetSsoProviders(): UseQueryResult<IPagination<IAuthProvider>, Error> {
return useQuery({
queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(),
@@ -1,5 +1,6 @@
import api from "@/lib/api-client.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { IPagination } from "@/lib/types.ts";
export async function getSsoProviderById(data: {
providerId: string;
@@ -8,8 +9,8 @@ export async function getSsoProviderById(data: {
return req.data;
}
export async function getSsoProviders(): Promise<IAuthProvider[]> {
const req = await api.post<IAuthProvider[]>("/sso/providers");
export async function getSsoProviders(): Promise<IPagination<IAuthProvider>> {
const req = await api.post<IPagination<IAuthProvider>>("/sso/providers");
return req.data;
}
@@ -1,20 +1,62 @@
import api from "@/lib/api-client";
import loadImage from "blueimp-load-image";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
async function compressAndResizeIcon(
file: File,
type: AvatarIconType,
): Promise<File> {
const isPng = file.type === "image/png";
const { image: canvas } = await loadImage(file, {
maxWidth: 300,
maxHeight: 300,
canvas: true,
orientation: true,
imageSmoothingQuality: "high",
});
if (type === AvatarIconType.AVATAR || !isPng) {
const ctx = (canvas as HTMLCanvasElement).getContext("2d")!;
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "source-over";
}
const outputType = isPng ? "image/png" : "image/jpeg";
return new Promise<File>((resolve, reject) => {
(canvas as HTMLCanvasElement).toBlob(
(blob) => {
if (!blob) {
reject(new Error("Failed to compress image"));
return;
}
resolve(new File([blob], file.name, { type: outputType }));
},
outputType,
isPng ? undefined : 0.85,
);
});
}
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const processed = await compressAndResizeIcon(file, type);
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
formData.append("image", processed);
return await api.post("/attachments/upload-image", formData, {
headers: {
@@ -1,8 +1,8 @@
import { useState } from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import useAuth from "@/features/auth/hooks/use-auth";
import { IForgotPassword } from "@/features/auth/types/auth.types";
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
@@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next";
const formSchema = z.object({
email: z
.string()
.min(1, { message: "Email is required" })
.email({ message: "Invalid email address" }),
.email()
.min(1, { message: "Email is required" }),
});
type FormValues = z.infer<typeof formSchema>;
export function ForgotPasswordForm() {
const { t } = useTranslation();
@@ -21,14 +21,14 @@ export function ForgotPasswordForm() {
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
useRedirectIfAuthenticated();
const form = useForm<IForgotPassword>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: IForgotPassword) {
async function onSubmit(data: FormValues) {
if (await forgotPassword(data)) {
setIsTokenSent(true);
}
@@ -1,5 +1,5 @@
import * as React from "react";
import * as z from "zod";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import {
@@ -11,9 +11,8 @@ import {
Box,
Stack,
} from "@mantine/core";
import { zodResolver } from "mantine-form-zod-resolver";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useParams, useSearchParams } from "react-router-dom";
import { IRegister } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
@@ -40,14 +39,14 @@ export function InviteSignUpForm() {
useRedirectIfAuthenticated();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
validate: zod4Resolver(formSchema),
initialValues: {
name: "",
password: "",
},
});
async function onSubmit(data: IRegister) {
async function onSubmit(data: FormValues) {
const invitationToken = searchParams.get("token");
await invitationSignup({
@@ -1,7 +1,7 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import useAuth from "@/features/auth/hooks/use-auth";
import { ILogin } from "@/features/auth/types/auth.types";
import {
Container,
Title,
@@ -24,11 +24,11 @@ import React from "react";
const formSchema = z.object({
email: z
.string()
.min(1, { message: "email is required" })
.email({ message: "Invalid email address" }),
.email()
.min(1, { message: "email is required" }),
password: z.string().min(1, { message: "Password is required" }),
});
type FormValues = z.infer<typeof formSchema>;
export function LoginForm() {
const { t } = useTranslation();
@@ -41,15 +41,15 @@ export function LoginForm() {
error,
} = useWorkspacePublicDataQuery();
const form = useForm<ILogin>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
email: "",
password: "",
},
});
async function onSubmit(data: ILogin) {
async function onSubmit(data: FormValues) {
await signIn(data);
}
@@ -1,7 +1,7 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import useAuth from "@/features/auth/hooks/use-auth";
import { IPasswordReset } from "@/features/auth/types/auth.types";
import { Box, Button, Container, PasswordInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
@@ -12,6 +12,7 @@ const formSchema = z.object({
.string()
.min(8, { message: "Password must contain at least 8 characters" }),
});
type FormValues = z.infer<typeof formSchema>;
interface PasswordResetFormProps {
resetToken?: string;
@@ -22,14 +23,14 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
const { passwordReset, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<IPasswordReset>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
newPassword: "",
},
});
async function onSubmit(data: IPasswordReset) {
async function onSubmit(data: FormValues) {
await passwordReset({
token: resetToken,
newPassword: data.newPassword,
@@ -1,6 +1,7 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import {
Container,
Title,
@@ -11,7 +12,6 @@ import {
Anchor,
Text,
} from "@mantine/core";
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useTranslation } from "react-i18next";
@@ -24,19 +24,19 @@ const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1).max(50),
email: z
.string()
.min(1, { message: "email is required" })
.email({ message: "Invalid email address" }),
.email()
.min(1, { message: "email is required" }),
password: z.string().min(8),
});
type FormValues = z.infer<typeof formSchema>;
export function SetupWorkspaceForm() {
const { t } = useTranslation();
const { setupWorkspace, isLoading } = useAuth();
// useRedirectIfAuthenticated();
const form = useForm<ISetupWorkspace>({
validate: zodResolver(formSchema),
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
workspaceName: "",
name: "",
@@ -45,7 +45,7 @@ export function SetupWorkspaceForm() {
},
});
async function onSubmit(data: ISetupWorkspace) {
async function onSubmit(data: FormValues) {
await setupWorkspace(data);
}
@@ -23,7 +23,7 @@ import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
@@ -44,11 +44,11 @@ export default function useAuth() {
// Check if MFA is required
if (response?.userHasMfa) {
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE + window.location.search);
} else if (response?.requiresMfaSetup) {
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED + window.location.search);
} else {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
} catch (err) {
setIsLoading(false);
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
export function useRedirectIfAuthenticated() {
@@ -9,7 +9,7 @@ export function useRedirectIfAuthenticated() {
useEffect(() => {
if (data && data?.user) {
navigate(APP_ROUTE.HOME);
navigate(getPostLoginRedirect());
}
}, [isLoading, data]);
}
@@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import { useEditor } from "@tiptap/react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
@@ -31,13 +30,12 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => {
if (document.querySelector("#mention")) return;
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation;
const emit = useQueryEmit();
const handleDialogClose = () => {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
@@ -55,6 +53,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
pageId: pageId,
content: JSON.stringify(comment),
selection: selectedText,
type: "inline",
};
const createdComment =
@@ -80,10 +79,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
);
}, 400);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} finally {
setShowCommentPopup(false);
setDraftCommentId("");
@@ -102,9 +97,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
size="lg"
radius="md"
w={300}
zIndex={180}
position={{ bottom: 500, right: 50 }}
withCloseButton
withBorder
data-comment-dialog
>
<Stack gap={2}>
<Group>
@@ -1,14 +1,15 @@
import { EditorContent, useEditor } from "@tiptap/react";
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
interface CommentEditorProps {
defaultContent?: any;
@@ -39,13 +40,29 @@ const CommentEditor = forwardRef(
StarterKit.configure({
gapcursor: false,
dropcursor: false,
link: false,
}),
Placeholder.configure({
placeholder: placeholder || t("Reply..."),
}),
Underline,
Link,
LinkExtension,
EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
],
editorProps: {
handleDOMEvents: {
@@ -60,7 +77,8 @@ const CommentEditor = forwardRef(
].includes(event.key)
) {
const emojiCommand = document.querySelector("#emoji-command");
if (emojiCommand) {
const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
return true;
}
}
@@ -84,9 +102,14 @@ const CommentEditor = forwardRef(
autofocus: (autofocus && "end") || false,
});
// Sync content from props for read-only editors (e.g. when updated via
// websocket on another browser). Skip for editable editors to avoid
// resetting the cursor position on every keystroke.
useEffect(() => {
commentEditor.commands.setContent(defaultContent);
}, [defaultContent]);
if (!editable && commentEditor && defaultContent) {
commentEditor.commands.setContent(defaultContent);
}
}, [defaultContent, editable, commentEditor]);
useEffect(() => {
setTimeout(() => {
@@ -103,7 +126,11 @@ const CommentEditor = forwardRef(
}));
return (
<div ref={focusRef} className={classes.commentEditor}>
<div
ref={focusRef}
className={classes.commentEditor}
data-editable={editable || undefined}
>
<EditorContent
editor={commentEditor}
className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
@@ -1,8 +1,8 @@
import { Group, Text, Box, Badge } from "@mantine/core";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { timeAgo } from "@/lib/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import CommentEditor from "@/features/comment/components/comment-editor";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
@@ -18,7 +18,6 @@ import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useTranslation } from "react-i18next";
interface CommentListItemProps {
@@ -40,12 +39,13 @@ function CommentListItem({
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const [currentUser] = useAtom(currentUserAtom);
const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
const createdAtAgo = useTimeAgo(comment.createdAt);
useEffect(() => {
setContent(comment.content);
@@ -56,15 +56,14 @@ function CommentListItem({
setIsLoading(true);
const commentToUpdate = {
commentId: comment.id,
content: JSON.stringify(content),
content: JSON.stringify(editContentRef.current ?? content),
};
await updateCommentMutation.mutateAsync(commentToUpdate);
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to update comment:", error);
} finally {
@@ -76,11 +75,6 @@ function CommentListItem({
try {
await deleteCommentMutation.mutateAsync(comment.id);
editor?.commands.unsetComment(comment.id);
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to delete comment:", error);
}
@@ -101,11 +95,6 @@ function CommentListItem({
if (editor) {
editor.commands.setCommentResolved(comment.id, !isResolved);
}
emit({
operation: "invalidateComment",
pageId: pageId,
});
} catch (error) {
console.error("Failed to toggle resolved state:", error);
}
@@ -128,6 +117,7 @@ function CommentListItem({
setIsEditing(true);
}
function cancelEdit() {
editContentRef.current = null;
setIsEditing(false);
}
@@ -171,7 +161,7 @@ function CommentListItem({
<Group gap="xs">
<Text size="xs" fw={500} c="dimmed">
{timeAgo(comment.createdAt)}
{createdAtAgo}
</Text>
</Group>
</div>
@@ -194,7 +184,7 @@ function CommentListItem({
<CommentEditor
defaultContent={content}
editable={true}
onUpdate={(newContent: any) => setContent(newContent)}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onSave={handleUpdateComment}
autofocus={true}
/>
@@ -1,6 +1,17 @@
import React, { useState, useRef, useCallback, memo, useMemo } from "react";
import { useParams } from "react-router-dom";
import { Divider, Paper, Tabs, Badge, Text, ScrollArea } from "@mantine/core";
import {
ActionIcon,
Center,
Divider,
Group,
Paper,
Stack,
Tabs,
Badge,
Text,
ScrollArea,
} from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import {
useCommentsQuery,
@@ -14,14 +25,8 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
function CommentListWithTabs() {
const { t } = useTranslation();
@@ -31,21 +36,12 @@ function CommentListWithTabs() {
data: comments,
isLoading: isCommentsLoading,
isError,
} = useCommentsQuery({ pageId: page?.id, limit: 100 });
} = useCommentsQuery({ pageId: page?.id });
const createCommentMutation = useCreateCommentMutation();
const [isLoading, setIsLoading] = useState(false);
const emit = useQueryEmit();
const isCloudEE = useIsCloudEE();
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page
);
const canComment = page?.permissions?.canEdit ?? false;
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
@@ -54,19 +50,47 @@ function CommentListWithTabs() {
}
const parentComments = comments.items.filter(
(comment: IComment) => comment.parentCommentId === null
(comment: IComment) => comment.parentCommentId === null,
);
const active = parentComments.filter(
(comment: IComment) => !comment.resolvedAt
(comment: IComment) => !comment.resolvedAt,
);
const resolved = parentComments.filter(
(comment: IComment) => comment.resolvedAt
(comment: IComment) => comment.resolvedAt,
);
return { activeComments: active, resolvedComments: resolved };
}, [comments]);
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
const handleAddPageComment = useCallback(
async (_commentId: string, content: string) => {
try {
setIsPageCommentLoading(true);
const createdComment = await createCommentMutation.mutateAsync({
pageId: page?.id,
content: JSON.stringify(content),
});
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 400);
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsPageCommentLoading(false);
}
},
[createCommentMutation, page?.id],
);
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
try {
@@ -78,18 +102,13 @@ function CommentListWithTabs() {
};
await createCommentMutation.mutateAsync(commentData);
emit({
operation: "invalidateComment",
pageId: page?.id,
});
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsLoading(false);
}
},
[createCommentMutation, page?.id]
[createCommentMutation, page?.id],
);
const renderComments = useCallback(
@@ -131,7 +150,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role]
[comments, handleAddReply, isLoading, space?.membership?.role],
);
if (isCommentsLoading) {
@@ -144,63 +163,32 @@ function CommentListWithTabs() {
const totalComments = activeComments.length + resolvedComments.length;
// If not cloud/enterprise, show simple list without tabs
if (!isCloudEE) {
if (totalComments === 0) {
return <>{t("No comments yet.")}</>;
}
return (
<ScrollArea style={{ height: "85vh" }} scrollbarSize={5} type="scroll">
<div style={{ paddingBottom: "200px" }}>
{comments?.items
.filter((comment: IComment) => comment.parentCommentId === null)
.map((comment) => (
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
userSpaceRole={space?.membership?.role}
/>
</div>
{canComment && (
<>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</>
)}
</Paper>
))}
</div>
</ScrollArea>
);
}
const pageCommentInput = canComment ? (
<PageCommentInput
onSave={handleAddPageComment}
isLoading={isPageCommentLoading}
/>
) : null;
return (
<div style={{ height: "85vh", display: "flex", flexDirection: "column", marginTop: '-15px' }}>
<Tabs defaultValue="open" variant="default" style={{ flex: "0 0 auto" }}>
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<Tabs
defaultValue="open"
variant="default"
style={{
flex: "1 1 auto",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<Tabs.List justify="center">
<Tabs.Tab
value="open"
@@ -225,16 +213,25 @@ function CommentListWithTabs() {
</Tabs.List>
<ScrollArea
style={{ flex: "1 1 auto", height: "calc(85vh - 60px)" }}
style={{ flex: "1 1 auto" }}
scrollbarSize={5}
type="scroll"
>
<div style={{ paddingBottom: "200px" }}>
<div style={{ paddingBottom: "8px" }}>
<Tabs.Panel value="open" pt="xs">
{activeComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No open comments.")}
</Text>
<Center py="xl">
<Stack align="center" gap="xs">
<IconMessageOff
size={32}
stroke={1.5}
color="var(--mantine-color-dimmed)"
/>
<Text size="sm" c="dimmed">
{t("No open comments.")}
</Text>
</Stack>
</Center>
) : (
activeComments.map(renderComments)
)}
@@ -242,9 +239,18 @@ function CommentListWithTabs() {
<Tabs.Panel value="resolved" pt="xs">
{resolvedComments.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{t("No resolved comments.")}
</Text>
<Center py="xl">
<Stack align="center" gap="xs">
<IconMessageOff
size={32}
stroke={1.5}
color="var(--mantine-color-dimmed)"
/>
<Text size="sm" c="dimmed">
{t("No resolved comments.")}
</Text>
</Stack>
</Center>
) : (
resolvedComments.map(renderComments)
)}
@@ -252,6 +258,7 @@ function CommentListWithTabs() {
</div>
</ScrollArea>
</Tabs>
{pageCommentInput}
</div>
);
}
@@ -273,9 +280,9 @@ const ChildComments = ({
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items]
[comments.items],
);
return (
@@ -303,7 +310,12 @@ const ChildComments = ({
const MemoizedChildComments = memo(ChildComments);
const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
const CommentEditorWithActions = ({
commentId,
onSave,
isLoading,
placeholder = undefined,
}) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
@@ -321,10 +333,57 @@ const CommentEditorWithActions = ({ commentId, onSave, isLoading }) => {
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={placeholder}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
const PageCommentInput = ({ onSave, isLoading }) => {
const { t } = useTranslation();
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const handleSave = useCallback(() => {
onSave(null, content);
setContent("");
commentEditorRef.current?.clearContent();
}, [content, onSave]);
return (
<div
ref={ref}
style={{
flex: "0 0 auto",
borderTop: "1px solid var(--mantine-color-default-border)",
paddingTop: "var(--mantine-spacing-sm)",
paddingBottom: 25,
position: "relative",
}}
>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
{focused && (
<ActionIcon
variant="filled"
radius="xl"
size="sm"
onClick={handleSave}
loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }}
>
<IconArrowUp size={16} />
</ActionIcon>
)}
</div>
);
};
export default CommentListWithTabs;
@@ -32,11 +32,14 @@
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
max-height: 20vh;
padding-left: 6px;
padding-right: 6px;
margin-top: 10px;
margin-bottom: 2px;
}
&[data-editable] .ProseMirror :global(.ProseMirror){
max-height: 50vh;
overflow: hidden auto;
}
@@ -1,8 +1,8 @@
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
InfiniteData,
} from "@tanstack/react-query";
import {
createComment,
@@ -17,17 +17,40 @@ import {
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
export function useCommentsQuery(
params: ICommentParams,
): UseQueryResult<IPagination<IComment>, Error> {
return useQuery({
export function useCommentsQuery(params: ICommentParams) {
const query = useInfiniteQuery({
queryKey: RQ_KEY(params.pageId),
queryFn: () => getPageComments(params),
queryFn: ({ pageParam }) =>
getPageComments({ pageId: params.pageId, cursor: pageParam, limit: 100 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
enabled: !!params.pageId,
});
useEffect(() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
query.fetchNextPage();
}
}, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
const data = useMemo<IPagination<IComment> | undefined>(() => {
if (!query.data) return undefined;
return {
items: query.data.pages.flatMap((p) => p.items),
meta: query.data.pages[query.data.pages.length - 1].meta,
};
}, [query.data]);
return {
data,
isLoading: query.isLoading || query.hasNextPage,
isError: query.isError,
};
}
export function useCreateCommentMutation() {
@@ -36,18 +59,26 @@ export function useCreateCommentMutation() {
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => createComment(data),
onSuccess: (data) => {
//const newComment = data;
// let comments = queryClient.getQueryData(RQ_KEY(data.pageId));
// if (comments) {
//comments = prevComments => [...prevComments, newComment];
//queryClient.setQueryData(RQ_KEY(data.pageId), comments);
//}
onSuccess: (newComment) => {
const cache = queryClient.getQueryData(
RQ_KEY(newComment.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache && cache.pages.length > 0) {
const lastIdx = cache.pages.length - 1;
queryClient.setQueryData(RQ_KEY(newComment.pageId), {
...cache,
pages: cache.pages.map((page, i) =>
i === lastIdx
? { ...page, items: [...page.items, newComment] }
: page,
),
});
}
queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) });
notifications.show({ message: t("Comment created successfully") });
},
onError: (error) => {
onError: () => {
notifications.show({
message: t("Error creating comment"),
color: "red",
@@ -57,14 +88,31 @@ export function useCreateCommentMutation() {
}
export function useUpdateCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
mutationFn: (data) => updateComment(data),
onSuccess: (data) => {
onSuccess: (updatedComment) => {
const cache = queryClient.getQueryData(
RQ_KEY(updatedComment.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(RQ_KEY(updatedComment.pageId), {
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.map((comment) =>
comment.id === updatedComment.id ? updatedComment : comment,
),
})),
});
}
notifications.show({ message: t("Comment updated successfully") });
},
onError: (error) => {
onError: () => {
notifications.show({
message: t("Failed to update comment"),
color: "red",
@@ -79,25 +127,24 @@ export function useDeleteCommentMutation(pageId?: string) {
return useMutation({
mutationFn: (commentId: string) => deleteComment(commentId),
onSuccess: (data, variables) => {
const comments = queryClient.getQueryData(
onSuccess: (_data, commentId) => {
const cache = queryClient.getQueryData(
RQ_KEY(pageId),
) as IPagination<IComment>;
) as InfiniteData<IPagination<IComment>> | undefined;
if (comments && comments.items) {
const commentId = variables;
const newComments = comments.items.filter(
(comment) => comment.id !== commentId,
);
if (cache) {
queryClient.setQueryData(RQ_KEY(pageId), {
...comments,
items: newComments,
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.filter((comment) => comment.id !== commentId),
})),
});
}
notifications.show({ message: t("Comment deleted successfully") });
},
onError: (error) => {
onError: () => {
notifications.show({
message: t("Failed to delete comment"),
color: "red",
@@ -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);
@@ -1,11 +1,53 @@
.bubbleMenu {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 100vw;
width: fit-content;
border-radius: 2px;
box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f);
border-radius: 6px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
> * {
flex-shrink: 0;
}
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.active {
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
}
}
.buttonRoot {
height: 34px;
padding-left: rem(8);
padding-right: rem(4);
border: none;
border-radius: 6px;
}
.buttonSeparator {
border-right: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) !important;
}
.divider {
width: 1px;
height: 16px;
align-self: center;
margin: 0 4px;
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-3)
);
}
@@ -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";
@@ -20,11 +21,13 @@ import {
draftCommentIdAtom,
showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai";
import { useAtom, useAtomValue } from "jotai";
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";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -39,14 +42,22 @@ 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 workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
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 +134,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showCommentPopupRef?.current
) {
return false;
@@ -146,9 +158,31 @@ 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: 199, position: "relative" }}
>
<div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
@@ -212,16 +246,18 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}}
/>
<ActionIcon
variant="default"
size="lg"
radius="0"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t(commentItem.name)}
style={{ border: "none" }}
onClick={commentItem.command}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</BubbleMenu>
);
@@ -1,7 +1,6 @@
import React, { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import {
ActionIcon,
Button,
Popover,
rem,
@@ -15,6 +14,8 @@ import {
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
export interface BubbleColorMenuItem {
name: string;
@@ -166,14 +167,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className="color-selector-trigger"
className={clsx(["color-selector-trigger", classes.buttonRoot])}
style={{
height: "34px",
border: "none",
fontWeight: 500,
fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}}
>
A
@@ -2,6 +2,7 @@ import { Dispatch, FC, SetStateAction, useCallback } from "react";
import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { useTranslation } from "react-i18next";
@@ -20,7 +21,15 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
const onLink = useCallback(
(url: string) => {
setIsOpen(false);
editor.chain().focus().setLink({ href: url }).run();
editor
.chain()
.focus()
.setLink({ href: url })
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
},
[editor, setIsOpen],
);
@@ -1,6 +1,7 @@
import React, { Dispatch, FC, SetStateAction } from "react";
import {
IconBlockquote,
IconCaretRightFilled,
IconCheck,
IconCheckbox,
IconChevronDown,
@@ -8,14 +9,16 @@ import {
IconH1,
IconH2,
IconH3,
IconInfoCircle,
IconList,
IconListNumbers,
IconTypography,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core";
import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import classes from "./bubble-menu.module.css";
interface NodeSelectorProps {
editor: Editor | null;
@@ -54,6 +57,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
isCallout: ctx.editor.isActive("callout"),
isDetails: ctx.editor.isActive("details"),
};
},
});
@@ -123,6 +128,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editorState?.isCodeBlock,
},
{
name: "Callout",
icon: IconInfoCircle,
command: () => editor.chain().focus().toggleCallout().run(),
isActive: () => editorState?.isCallout,
},
{
name: "Toggle block",
icon: IconCaretRightFilled,
command: () => editor.chain().focus().setDetails().run(),
isActive: () => editorState?.isDetails,
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
@@ -132,15 +149,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
return (
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{t(activeItem?.name)}
</Button>
<Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
<Button
className={classes.buttonRoot}
variant="default"
style={{ border: "none", height: "34px" }}
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
{t(activeItem?.name)}
</Button>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
@@ -7,7 +7,7 @@ import {
IconCheck,
IconChevronDown,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
@@ -84,16 +84,18 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
return (
<Popover opened={isOpen} withArrow>
<Popover.Target>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
px="5"
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
<Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
<Button
variant="default"
style={{ border: "none", height: "34px" }}
px="5"
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>

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