Merge branch 'main' into verify

This commit is contained in:
Philipinho
2026-04-11 16:43:12 +01:00
339 changed files with 19210 additions and 6030 deletions
@@ -0,0 +1,106 @@
import { useEffect, useRef } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import { useChatStream } from "../hooks/use-chat-stream";
import ChatMessageList from "./chat-message-list";
import ChatEmptyState from "./chat-empty-state";
import ChatInput from "./chat-input";
import type { HomeAiPromptInitialState } from "@/features/home/components/home-ai-prompt";
import classes from "../styles/ai-chat.module.css";
export default function AiChatLayout() {
const { chatId } = useParams<{ chatId: string }>();
const location = useLocation();
const navigate = useNavigate();
const chatInfoQuery = useChatInfoQuery(chatId);
// If the URL points at a chat the user does not own, the info fetch 404s.
// Bounce them back to /ai so they cannot interact with any chat UI (including
// kicking off orphan uploads) tied to a chat they have no access to.
useEffect(() => {
if (chatId && chatInfoQuery.isError) {
navigate("/ai", { replace: true });
}
}, [chatId, chatInfoQuery.isError, navigate]);
const {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
sendMessage,
stopGeneration,
hydrateFromServer,
} = useChatStream(chatId);
const autoSentRef = useRef(false);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, hydrateFromServer]);
useEffect(() => {
if (autoSentRef.current || chatId) return;
const state = location.state as HomeAiPromptInitialState | null;
if (!state?.initialContent && !state?.initialAttachments?.length) return;
autoSentRef.current = true;
sendMessage(
state.initialContent ?? "",
state.initialMentions ?? [],
state.initialAttachments ?? [],
);
navigate(location.pathname, { replace: true, state: null });
}, [chatId, location, navigate, sendMessage]);
const hasMessages = messages.length > 0 || isStreaming;
// While the redirect effect is running (or if the user is still on this
// component for any reason) never render the chat UI for a forbidden chat.
if (chatId && chatInfoQuery.isError) {
return null;
}
return (
<div className={classes.main}>
{error && (
<div
style={{
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-lg)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-sm)",
}}
>
{error}
</div>
)}
{hasMessages ? (
<>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
chatId={chatId}
/>
</div>
</>
) : (
<ChatEmptyState
isStreaming={isStreaming}
onSend={sendMessage}
onStop={stopGeneration}
/>
)}
</div>
);
}
@@ -0,0 +1,166 @@
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { ActionIcon, Menu, TextInput } from "@mantine/core";
import { IconDots, IconTrash, IconEdit } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { AiChat } from "../types/ai-chat.types";
import classes from "../styles/chat-sidebar.module.css";
type Props = {
chat: AiChat;
isActive: boolean;
onDelete: (chatId: string) => void;
onRename: (chatId: string, title: string) => void;
};
function formatChatDate(
isoString: string | Date,
locale: string | undefined,
): string {
const date = new Date(isoString);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const ts = date.getTime();
const sameYear = date.getFullYear() === now.getFullYear();
if (ts >= startOfToday) {
return date.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
});
}
if (sameYear) {
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
}
return date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
year: "numeric",
});
}
export default function AiChatSidebarItem({
chat,
isActive,
onDelete,
onRename,
}: Props) {
const { t, i18n } = useTranslation();
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const formattedDate = useMemo(
() => formatChatDate(chat.updatedAt, i18n.language),
[chat.updatedAt, i18n.language],
);
useEffect(() => {
if (renaming) {
// Wait for the input to be mounted before selecting.
const id = window.setTimeout(() => inputRef.current?.select(), 0);
return () => window.clearTimeout(id);
}
}, [renaming]);
const startRename = useCallback(() => {
setRenameValue(chat.title || "");
setRenaming(true);
}, [chat.title]);
const submitRename = useCallback(() => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== chat.title) {
onRename(chat.id, trimmed);
}
setRenaming(false);
}, [renameValue, chat.id, chat.title, onRename]);
if (renaming) {
return (
<div className={classes.chatItem} data-active={isActive || undefined}>
<TextInput
ref={inputRef}
size="xs"
variant="unstyled"
placeholder={t("Chat name")}
value={renameValue}
onChange={(e) => setRenameValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitRename();
} else if (e.key === "Escape") {
e.preventDefault();
setRenaming(false);
}
}}
onBlur={submitRename}
classNames={{ input: classes.chatItemRenameInput }}
style={{ flex: 1 }}
/>
</div>
);
}
return (
<Link
to={`/ai/chat/${chat.id}`}
className={classes.chatItem}
data-active={isActive || undefined}
>
<span className={classes.chatItemTitle}>
{chat.title || t("Untitled chat")}
</span>
<span className={classes.chatItemDate}>{formattedDate}</span>
<div className={classes.chatItemActions}>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="subtle"
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
>
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={14} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startRename();
}}
>
{t("Rename")}
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(chat.id);
}}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
</Link>
);
}
@@ -0,0 +1,224 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
useChatsQuery,
useDeleteChatMutation,
useUpdateChatTitleMutation,
useSearchChatsQuery,
} from "../queries/ai-chat-query";
import AiChatSidebarItem from "./ai-chat-sidebar-item";
import type { AiChat } from "../types/ai-chat.types";
import classes from "../styles/chat-sidebar.module.css";
type ChatGroup = { key: string; label: string; chats: AiChat[] };
function groupChatsByAge(
chats: AiChat[],
t: (key: string) => string,
): ChatGroup[] {
if (chats.length === 0) return [];
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
const startOfLast7 = startOfToday - 7 * 24 * 60 * 60 * 1000;
const startOfLast30 = startOfToday - 30 * 24 * 60 * 60 * 1000;
const buckets: Record<string, ChatGroup> = {
today: { key: "today", label: t("Today"), chats: [] },
yesterday: { key: "yesterday", label: t("Yesterday"), chats: [] },
last7: { key: "last7", label: t("Previous 7 days"), chats: [] },
last30: { key: "last30", label: t("Previous 30 days"), chats: [] },
older: { key: "older", label: t("Older"), chats: [] },
};
for (const chat of chats) {
const ts = new Date(chat.updatedAt).getTime();
if (ts >= startOfToday) buckets.today.chats.push(chat);
else if (ts >= startOfYesterday) buckets.yesterday.chats.push(chat);
else if (ts >= startOfLast7) buckets.last7.chats.push(chat);
else if (ts >= startOfLast30) buckets.last30.chats.push(chat);
else buckets.older.chats.push(chat);
}
return [
buckets.today,
buckets.yesterday,
buckets.last7,
buckets.last30,
buckets.older,
].filter((b) => b.chats.length > 0);
}
export default function AiChatSidebar() {
const { t } = useTranslation();
const navigate = useNavigate();
const { chatId } = useParams<{ chatId: string }>();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const chatsQuery = useChatsQuery();
const searchQuery = useSearchChatsQuery(debouncedSearch);
const deleteMutation = useDeleteChatMutation();
const renameMutation = useUpdateChatTitleMutation();
const chats = useMemo(() => {
if (debouncedSearch) {
return searchQuery.data || [];
}
return chatsQuery.data?.pages.flatMap((p) => p.items) || [];
}, [debouncedSearch, searchQuery.data, chatsQuery.data]);
const groupedChats = useMemo(() => groupChatsByAge(chats, t), [chats, t]);
const sentinelRef = useRef<HTMLDivElement>(null);
const { hasNextPage, fetchNextPage, isFetchingNextPage } = chatsQuery;
const isSearching = Boolean(debouncedSearch);
useEffect(() => {
if (isSearching) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [isSearching, hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleNewChat = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
navigate("/ai");
},
[navigate],
);
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
},
[deleteMutation, chatId, navigate],
);
const handleRename = useCallback(
(chatId: string, title: string) => {
renameMutation.mutate({ chatId, title });
},
[renameMutation],
);
const isLoading = chatsQuery.isLoading || searchQuery.isLoading;
return (
<div className={classes.sidebar}>
<div className={classes.header}>
<span className={classes.title}>{t("AI Chat")}</span>
<Tooltip label={t("New chat")} openDelay={250} withArrow>
<ActionIcon
component={Link}
to="/ai"
variant="subtle"
color="gray"
onClick={handleNewChat}
aria-label={t("New chat")}
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
</div>
<TextInput
className={classes.searchInput}
placeholder="Search chats..."
leftSection={<IconSearch size={14} />}
size="xs"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<div className={classes.chatList}>
{isLoading && <Loader size="xs" mx="auto" mt="md" />}
{!isLoading && chats.length === 0 && (
<div className={classes.chatListEmpty}>
<IconMessageCircle2
size={28}
stroke={1.5}
className={classes.chatListEmptyIcon}
/>
<div className={classes.chatListEmptyTitle}>
{isSearching ? t("No chats found") : t("No conversations yet")}
</div>
<div className={classes.chatListEmptyHint}>
{isSearching
? t("Try a different search term.")
: t("Start a new chat to see it here.")}
</div>
</div>
)}
{isSearching
? chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
isActive={chat.id === chatId}
onDelete={handleDelete}
onRename={handleRename}
/>
))
: groupedChats.map((group) => (
<div key={group.key} className={classes.chatGroup}>
<div className={classes.chatGroupLabel}>{group.label}</div>
{group.chats.map((chat) => (
<AiChatSidebarItem
key={chat.id}
chat={chat}
isActive={chat.id === chatId}
onDelete={handleDelete}
onRename={handleRename}
/>
))}
</div>
))}
{!isSearching && (
<>
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import { TextInput, Loader, Text, ScrollArea } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { useChatsQuery, useSearchChatsQuery } from "../queries/ai-chat-query";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import classes from "../styles/aside-chat-panel.module.css";
type Props = {
activeChatId: string | undefined;
onSelect: (chatId: string) => void;
};
export default function AsideChatHistory({ activeChatId, onSelect }: Props) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedSearch] = useDebouncedValue(searchValue, 300);
const chatsQuery = useChatsQuery();
const searchQuery = useSearchChatsQuery(debouncedSearch);
const isSearching = debouncedSearch.length > 0;
const chats = isSearching
? (searchQuery.data ?? [])
: (chatsQuery.data?.pages.flatMap((p) => p.items) ?? []);
const isLoading = isSearching ? searchQuery.isLoading : chatsQuery.isLoading;
return (
<div>
<TextInput
placeholder={t("Search chats...")}
leftSection={<IconSearch size={14} />}
size="xs"
mb="xs"
value={searchValue}
onChange={(e) => setSearchValue(e.currentTarget.value)}
/>
{isLoading ? (
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
<Loader size="sm" />
</div>
) : chats.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
{isSearching ? t("No chats found") : t("No chat history")}
</Text>
) : (
<ScrollArea.Autosize mah={300} scrollbars="y">
<div className={classes.historyList}>
{chats.map((chat) => (
<div
key={chat.id}
className={classes.historyItem}
data-active={chat.id === activeChatId || undefined}
onClick={() => onSelect(chat.id)}
>
<span className={classes.historyItemTitle}>
{chat.title || t("Untitled chat")}
</span>
</div>
))}
</div>
</ScrollArea.Autosize>
)}
</div>
);
}
@@ -0,0 +1,258 @@
import { useState, useEffect, useCallback } from "react";
import { ActionIcon, Popover, Tooltip, UnstyledButton } from "@mantine/core";
import {
IconPlus,
IconChevronDown,
IconArrowsDiagonal,
IconX,
IconSparkles,
IconFileText,
IconLanguage,
IconSearch,
} from "@tabler/icons-react";
import { useAtom } from "jotai";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { usePageQuery } from "@/features/page/queries/page-query";
import { extractPageSlugId } from "@/lib";
import { useChatStream } from "../hooks/use-chat-stream";
import { useChatInfoQuery } from "../queries/ai-chat-query";
import ChatMessageList from "./chat-message-list";
import ChatInput from "./chat-input";
import AsideChatHistory from "./aside-chat-history";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/aside-chat-panel.module.css";
type QuickAction = {
icon: React.ReactNode;
label: string;
prompt: string;
};
export default function AsideChatPanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const [, setAsideState] = useAtom(asideStateAtom);
const [chatId, setChatId] = useState<string | undefined>(undefined);
const [historyOpen, setHistoryOpen] = useState(false);
const [contextPages, setContextPages] = useState<PageMention[]>([]);
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const { data: page } = usePageQuery({ pageId: slugId });
const chatInfoQuery = useChatInfoQuery(chatId);
const {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
sendMessage,
stopGeneration,
hydrateFromServer,
} = useChatStream(chatId, {
onChatCreated: (newChatId) => {
setChatId(newChatId);
},
});
useEffect(() => {
if (page && !chatId) {
setContextPages([{ id: page.id, title: page.title || "", slugId: page.slugId }]);
}
}, [page, chatId]);
const handleRemoveContextPage = useCallback((pageId: string) => {
setContextPages((prev) => prev.filter((p) => p.id !== pageId));
}, []);
useEffect(() => {
if (chatInfoQuery.data?.messages) {
hydrateFromServer(chatInfoQuery.data.messages);
}
}, [chatInfoQuery.data, hydrateFromServer]);
// Drop the open chatId if the current user lost access to it (404/403 on
// the info fetch). Reverts the panel to a fresh chat instead of presenting
// an input tied to a chat the user does not own.
useEffect(() => {
if (chatId && chatInfoQuery.isError) {
setChatId(undefined);
}
}, [chatId, chatInfoQuery.isError]);
const handleNewChat = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
setChatId(undefined);
if (page) {
setContextPages([
{ id: page.id, title: page.title || "", slugId: page.slugId },
]);
}
},
[page],
);
const handleSelectChat = useCallback((selectedChatId: string) => {
setChatId(selectedChatId);
setHistoryOpen(false);
}, []);
const handleExpand = useCallback(() => {
if (chatId) {
navigate(`/ai/chat/${chatId}`);
} else {
navigate("/ai");
}
setAsideState({ tab: "", isAsideOpen: false });
}, [chatId, navigate, setAsideState]);
const handleClose = useCallback(() => {
setAsideState({ tab: "", isAsideOpen: false });
}, [setAsideState]);
const handleSend = useCallback(
(content: string, mentions: PageMention[], attachments: ChatAttachment[]) => {
const contextPageId = contextPages.length > 0 ? contextPages[0].id : undefined;
sendMessage(content, mentions, attachments, contextPageId);
},
[sendMessage, contextPages],
);
const handleQuickAction = useCallback(
(prompt: string) => {
handleSend(prompt, [], []);
},
[handleSend],
);
const hasMessages = messages.length > 0 || isStreaming;
const quickActions: QuickAction[] = [
{ icon: <IconFileText size={16} />, label: t("Summarize this page"), prompt: "Summarize this page" },
{ icon: <IconLanguage size={16} />, label: t("Translate this page"), prompt: "Translate this page" },
{ icon: <IconSearch size={16} />, label: t("Analyze for insights"), prompt: "Analyze this page for insights" },
];
return (
<div className={classes.panel}>
<div className={classes.toolbar}>
<Popover
opened={historyOpen}
onChange={setHistoryOpen}
position="bottom-start"
width={280}
shadow="md"
>
<Popover.Target>
<UnstyledButton
className={classes.titleButton}
onClick={() => setHistoryOpen((o) => !o)}
>
<span className={classes.titleText}>
{chatInfoQuery.data?.chat?.title || t("New chat")}
</span>
<IconChevronDown size={16} stroke={1.75} />
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<AsideChatHistory activeChatId={chatId} onSelect={handleSelectChat} />
</Popover.Dropdown>
</Popover>
<div className={classes.toolbarSpacer} />
<Tooltip label={t("New chat")} openDelay={250}>
<ActionIcon
component="a"
href="/ai"
variant="subtle"
color="dark"
onClick={handleNewChat}
>
<IconPlus size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
<IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
<IconX size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
</div>
{error && (
<div
style={{
padding: "var(--mantine-spacing-xs) var(--mantine-spacing-sm)",
color: "var(--mantine-color-red-6)",
fontSize: "var(--mantine-font-size-xs)",
}}
>
{error}
</div>
)}
{hasMessages ? (
<>
<div className={classes.messages} data-aside-chat>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</div>
</>
) : (
<div className={classes.emptyState}>
<IconSparkles size={36} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateTitle}>{t("How can I help you today?")}</div>
<div className={classes.quickActions}>
{quickActions.map((action) => (
<button
key={action.label}
type="button"
className={classes.quickAction}
onClick={() => handleQuickAction(action.prompt)}
>
<span className={classes.quickActionIcon}>{action.icon}</span>
{action.label}
</button>
))}
</div>
</div>
)}
<div className={classes.inputArea}>
<ChatInput
isStreaming={isStreaming}
onSend={handleSend}
onStop={stopGeneration}
placeholder={t("Ask anything...")}
autofocus={false}
contextPages={contextPages}
onRemoveContextPage={handleRemoveContextPage}
variant="flat"
chatId={chatId}
/>
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import {
IconSparkles,
IconSearch,
IconFilePlus,
IconEdit,
IconFileText,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import ChatInput from "./chat-input";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/ai-chat.module.css";
type Suggestion = {
icon: React.ReactNode;
text: string;
prompt: string;
};
const SUGGESTIONS: Suggestion[] = [
{
icon: <IconSearch size={16} />,
text: "Search across all pages",
prompt: "Search for pages about ",
},
{
icon: <IconFilePlus size={16} />,
text: "Create a new page",
prompt: "Create a new page titled ",
},
{
icon: <IconFileText size={16} />,
text: "Summarize a page",
prompt: "Summarize the page @",
},
{
icon: <IconEdit size={16} />,
text: "Update page content",
prompt: "Update the page @",
},
];
type Props = {
isStreaming: boolean;
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
onStop: () => void;
};
export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
const { t } = useTranslation();
const handleSuggestionClick = (prompt: string) => {
onSend(prompt, [], []);
};
return (
<div className={classes.emptyState}>
<IconSparkles size={48} stroke={1.5} className={classes.emptyStateIcon} />
<div className={classes.emptyStateBrand}>{t("Docmost AI")}</div>
<div className={classes.emptyStateTitle}>
{t("What can I help you with?")}
</div>
<div className={classes.emptyStateInput}>
<ChatInput
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
placeholder="Ask anything... Use @ to mention pages"
autofocus
/>
</div>
<div className={classes.suggestionsSection}>
<div className={classes.suggestionsLabel}>Get started</div>
<div className={classes.suggestionsGrid}>
{SUGGESTIONS.map((s) => (
<button
key={s.text}
type="button"
className={classes.suggestionCard}
onClick={() => handleSuggestionClick(s.prompt)}
>
<span className={classes.suggestionIcon}>{s.icon}</span>
<span className={classes.suggestionText}>{s.text}</span>
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,409 @@
import { useCallback, useRef, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconArrowUp, IconPaperclip, IconPlayerStopFilled, IconX, IconFile, IconPhoto, IconPlus, IconAt, IconFileText } from "@tabler/icons-react";
import { Popover } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder";
import { CharacterCount } from "@tiptap/extensions";
import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
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";
import { uploadChatFile } from "../services/ai-chat-service";
import type { ChatAttachment, PageMention } from "../types/ai-chat.types";
import classes from "../styles/chat-input.module.css";
type PendingAttachment = ChatAttachment & { uploading: boolean };
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
const ACCEPTED_FILE_TYPES = ".pdf,.docx,.txt,.csv,.md,.png,.jpg,.jpeg,.webp";
// Kept in sync with MAX_ATTACHMENTS_PER_MESSAGE in apps/server/src/ee/ai-chat/ai-chat-limits.ts
const MAX_ATTACHMENTS_PER_MESSAGE = 5;
type Props = {
isStreaming: boolean;
onSend: (content: string, mentions: PageMention[], attachments: ChatAttachment[]) => void;
onStop: () => void;
placeholder?: string;
autofocus?: boolean;
contextPages?: PageMention[];
onRemoveContextPage?: (pageId: string) => void;
variant?: "card" | "flat";
showDisclaimer?: boolean;
chatId?: string;
};
function extractMentions(json: any): PageMention[] {
const mentions: PageMention[] = [];
const seen = new Set<string>();
function walk(node: any) {
if (node.type === "mention" && node.attrs?.entityType === "page" && node.attrs?.entityId) {
if (!seen.has(node.attrs.entityId)) {
seen.add(node.attrs.entityId);
mentions.push({
id: node.attrs.entityId,
title: node.attrs.label || "",
slugId: node.attrs.slugId || "",
});
}
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return mentions;
}
function editorJsonToText(json: any): string {
let text = "";
function walk(node: any) {
if (node.type === "text") {
text += node.text || "";
} else if (node.type === "mention") {
text += `@${node.attrs?.label || ""}`;
} else if (node.type === "paragraph") {
if (text.length > 0) text += "\n";
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
return;
}
if (node.content) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return text;
}
export default function ChatInput({
isStreaming,
onSend,
onStop,
placeholder,
autofocus = true,
contextPages,
onRemoveContextPage,
variant = "card",
showDisclaimer = true,
chatId,
}: Props) {
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
const { t } = useTranslation();
const [isEmpty, setIsEmpty] = useState(true);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [plusMenuOpen, setPlusMenuOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const onSendRef = useRef(onSend);
onSendRef.current = onSend;
const handleFileSelect = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
const room = MAX_ATTACHMENTS_PER_MESSAGE - pendingAttachments.length;
if (room <= 0) {
notifications.show({
color: "yellow",
message: t("You can attach up to {{max}} files per message.", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
}),
});
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
const incoming = Array.from(files);
const accepted = incoming.slice(0, room);
if (incoming.length > accepted.length) {
notifications.show({
color: "yellow",
message: t(
"Only the first {{n}} file(s) were added (max {{max}} per message).",
{ n: accepted.length, max: MAX_ATTACHMENTS_PER_MESSAGE },
),
});
}
for (const file of accepted) {
const tempId = `uploading-${Date.now()}-${Math.random()}`;
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const placeholder: PendingAttachment = {
id: tempId,
fileName: file.name,
fileExt: ext,
fileSize: file.size,
mimeType: file.type,
uploading: true,
};
setPendingAttachments((prev) => [...prev, placeholder]);
try {
const uploaded = await uploadChatFile(file, chatIdRef.current);
setPendingAttachments((prev) =>
prev.map((a) =>
a.id === tempId ? { ...uploaded, uploading: false } : a,
),
);
} catch {
setPendingAttachments((prev) => prev.filter((a) => a.id !== tempId));
}
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [pendingAttachments.length, t]);
const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
}, []);
const handleSubmit = useCallback(() => {
if (!editor || isStreaming) return;
const json = editor.getJSON();
const text = editorJsonToText(json).trim();
const readyAttachments = pendingAttachments.filter((a) => !a.uploading);
if (!text && readyAttachments.length === 0) return;
const mentions = extractMentions(json);
onSendRef.current(text, mentions, readyAttachments);
editor.commands.clearContent();
editor.commands.focus();
setPendingAttachments([]);
}, [isStreaming, pendingAttachments]);
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const editor = useEditor({
extensions: [
StarterKit.configure({
gapcursor: false,
dropcursor: false,
link: false,
}),
Placeholder.configure({
placeholder: placeholder || "Ask anything... Use @ to mention pages",
}),
CharacterCount.configure({
limit: 50000,
}),
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: {
keydown: (_view, event) => {
if (
["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(
event.key,
)
) {
const emojiCommand = document.querySelector("#emoji-command");
const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
return true;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmitRef.current();
return true;
}
},
},
},
content: "",
editable: true,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
onUpdate: ({ editor: e }) => {
setIsEmpty(!e.getText().trim());
},
});
useEffect(() => {
if (editor && autofocus) {
editor.commands.focus();
}
}, [editor]);
const hasContent = !isEmpty || pendingAttachments.some((a) => !a.uploading) || (contextPages?.length ?? 0) > 0;
const wrapperClass = variant === "flat" ? classes.inputWrapperFlat : classes.inputWrapper;
return (
<>
<div className={wrapperClass} data-chat-input>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES}
multiple
style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
{((contextPages?.length ?? 0) > 0 || pendingAttachments.length > 0) && (
<div className={classes.attachmentChips}>
{contextPages?.map((page) => (
<div key={page.id} className={classes.attachmentChip}>
<IconFileText size={14} />
<span className={classes.attachmentChipName}>
{page.title || "Untitled"}
</span>
{onRemoveContextPage && (
<button
type="button"
className={classes.attachmentChipRemove}
onClick={() => onRemoveContextPage(page.id)}
aria-label={`Remove ${page.title}`}
>
<IconX size={12} />
</button>
)}
</div>
))}
{pendingAttachments.map((attachment) => (
<div
key={attachment.id}
className={`${classes.attachmentChip} ${attachment.uploading ? classes.attachmentChipUploading : ""}`}
>
{IMAGE_EXTENSIONS.includes(attachment.fileExt) ? (
<IconPhoto size={14} />
) : (
<IconFile size={14} />
)}
<span className={classes.attachmentChipName}>
{attachment.fileName}
</span>
{!attachment.uploading && (
<button
type="button"
className={classes.attachmentChipRemove}
onClick={() => removeAttachment(attachment.id)}
aria-label={`Remove ${attachment.fileName}`}
>
<IconX size={12} />
</button>
)}
</div>
))}
</div>
)}
<EditorContent editor={editor} className={classes.editorContent} />
<div className={classes.actions}>
<Popover opened={plusMenuOpen} onChange={setPlusMenuOpen} position="top-start" width={220} shadow="md">
<Popover.Target>
<button
type="button"
className={classes.plusButton}
onClick={() => setPlusMenuOpen((o) => !o)}
aria-label="Add content"
>
<IconPlus size={14} />
</button>
</Popover.Target>
<Popover.Dropdown p={4}>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
fileInputRef.current?.click();
setPlusMenuOpen(false);
}}
disabled={pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE}
title={
pendingAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE
? t("Max {{max}} files per message", {
max: MAX_ATTACHMENTS_PER_MESSAGE,
})
: undefined
}
>
<IconPaperclip size={16} className={classes.plusMenuIcon} />
{t("Add files")}
</button>
<button
type="button"
className={classes.plusMenuItem}
onClick={() => {
editor?.commands.insertContent("@");
editor?.commands.focus();
setPlusMenuOpen(false);
}}
>
<IconAt size={16} className={classes.plusMenuIcon} />
Mention a page
</button>
</Popover.Dropdown>
</Popover>
<div style={{ flex: 1 }} />
{isStreaming ? (
<button
type="button"
className={classes.stopButton}
onClick={onStop}
aria-label="Stop generation"
>
<IconPlayerStopFilled size={14} />
</button>
) : (
<button
type="button"
className={classes.sendButton}
onClick={handleSubmit}
disabled={!hasContent}
aria-label="Send message"
>
<IconArrowUp size={16} stroke={2.5} />
</button>
)}
</div>
</div>
{showDisclaimer && (
<div className={classes.disclaimer}>
{t("AI-generated content may not be accurate.")}
</div>
)}
</>
);
}
@@ -0,0 +1,174 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { IconArrowDown, IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatMessage from "./chat-message";
import classes from "../styles/ai-chat.module.css";
function ChatMessageErrorFallback() {
const { t } = useTranslation();
return (
<div className={classes.messageErrorFallback}>
<IconAlertTriangle size={14} />
<span>{t("Failed to render this message.")}</span>
</div>
);
}
type Props = {
messages: AiChatMessage[];
isStreaming: boolean;
streamingContent: string;
streamingToolCalls: AiChatToolCall[];
};
const BOTTOM_THRESHOLD_PX = 32;
const SCROLL_UP_THRESHOLD_PX = 5;
const SMOOTH_SCROLL_SETTLE_MS = 600;
export default function ChatMessageList({
messages,
isStreaming,
streamingContent,
streamingToolCalls,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const isAutoScrollingRef = useRef(false);
const prevScrollTopRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
isAutoScrollingRef.current = true;
const target = container.scrollHeight - container.clientHeight;
container.scrollTo({ top: target, behavior });
prevScrollTopRef.current = target;
isAtBottomRef.current = true;
setShowScrollButton(false);
if (behavior === "smooth") {
setTimeout(() => {
isAutoScrollingRef.current = false;
if (containerRef.current) {
prevScrollTopRef.current = containerRef.current.scrollTop;
}
}, SMOOTH_SCROLL_SETTLE_MS);
} else {
isAutoScrollingRef.current = false;
}
}, []);
const handleScroll = useCallback(() => {
if (isAutoScrollingRef.current) return;
const container = containerRef.current;
if (!container) return;
const currentScrollTop = container.scrollTop;
const scrolledUp =
currentScrollTop < prevScrollTopRef.current - SCROLL_UP_THRESHOLD_PX;
prevScrollTopRef.current = currentScrollTop;
const distanceFromBottom =
container.scrollHeight - currentScrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
if (scrolledUp) {
isAtBottomRef.current = atBottom;
} else if (atBottom) {
isAtBottomRef.current = true;
}
setShowScrollButton(!atBottom);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
// Instant scroll during streaming to keep up with rapid updates
useEffect(() => {
if (isAtBottomRef.current) {
scrollToBottom("instant");
}
}, [streamingContent, streamingToolCalls.length, scrollToBottom]);
// Smooth scroll for new messages. Always force-scroll when the latest
// message is from the user (they just sent it), even if they were reading
// scrollback.
useEffect(() => {
const lastMessage = messages[messages.length - 1];
const lastIsUser = lastMessage?.role === "user";
if (lastIsUser || isAtBottomRef.current) {
scrollToBottom("smooth");
return;
}
// No auto-scroll: recompute from actual layout so that chat switches to
// content that doesn't overflow correctly hide the button even when no
// scroll event fires.
const container = containerRef.current;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX;
isAtBottomRef.current = atBottom;
setShowScrollButton(!atBottom);
}, [messages, scrollToBottom]);
return (
<div className={classes.messageListWrapper}>
<div ref={containerRef} className={classes.messageList}>
{messages.map((msg) => (
<ErrorBoundary
key={msg.id}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage message={msg} />
</ErrorBoundary>
))}
{isStreaming && (
<ErrorBoundary
resetKeys={[streamingContent, streamingToolCalls.length]}
fallback={<ChatMessageErrorFallback />}
>
<ChatMessage
message={{
id: "streaming",
chatId: "",
role: "assistant",
content: null,
toolCalls: null,
metadata: null,
createdAt: new Date().toISOString(),
}}
isStreaming
streamingContent={streamingContent}
streamingToolCalls={streamingToolCalls}
/>
</ErrorBoundary>
)}
<div ref={bottomRef} />
</div>
{showScrollButton && (
<button
type="button"
aria-label="Scroll to bottom"
className={classes.scrollToBottomButton}
onClick={() => scrollToBottom("smooth")}
>
<IconArrowDown size={16} stroke={2} />
</button>
)}
</div>
);
}
@@ -0,0 +1,139 @@
import { useCallback } from "react";
import { useNavigate } from "react-router";
import DOMPurify from "dompurify";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconCheck,
IconCopy,
IconFile,
IconLoader2,
IconPhoto,
} from "@tabler/icons-react";
import { markdownToHtml } from "@docmost/editor-ext";
import { CopyButton } from "@/components/common/copy-button";
import type { AiChatMessage, AiChatToolCall } from "../types/ai-chat.types";
import ChatToolGroup from "./chat-tool-group";
import classes from "../styles/chat-message.module.css";
import CopyTextButton from "@/components/common/copy.tsx";
const chatSanitizer = DOMPurify();
chatSanitizer.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
const href = node.getAttribute("href") || "";
if (href.startsWith("http://") || href.startsWith("https://")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
}
});
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "gif"];
type Props = {
message: AiChatMessage;
isStreaming?: boolean;
streamingContent?: string;
streamingToolCalls?: AiChatToolCall[];
};
export default function ChatMessage({
message,
isStreaming,
streamingContent,
streamingToolCalls,
}: Props) {
const navigate = useNavigate();
const handleContentClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const anchor = target.closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (href && (href.startsWith("/s/") || href.startsWith("/p/"))) {
e.preventDefault();
navigate(href);
}
},
[navigate],
);
if (message.role === "tool") return null;
const isUser = message.role === "user";
const content = isStreaming ? streamingContent : message.content;
const toolCalls = isStreaming ? streamingToolCalls : message.toolCalls;
if (isUser) {
const displayContent = (content || "").replace(
/\n\n<referenced_pages>[\s\S]*<\/referenced_pages>$/,
"",
);
const attachments =
(message.metadata?.attachments as {
id: string;
fileName: string;
fileExt: string;
}[]) || [];
return (
<div className={classes.userMessage}>
<div className={classes.userBubble}>
{attachments.length > 0 && (
<div className={classes.messageAttachments}>
{attachments.map((a) => (
<span key={a.id} className={classes.messageAttachmentChip}>
{IMAGE_EXTENSIONS.includes(a.fileExt) ? (
<IconPhoto size={13} />
) : (
<IconFile size={13} />
)}
{a.fileName}
</span>
))}
</div>
)}
{displayContent}
</div>
</div>
);
}
return (
<div className={classes.assistantMessage}>
<div className={classes.messageContent}>
{toolCalls && toolCalls.length > 0 && (
<ChatToolGroup toolCalls={toolCalls} isStreaming={isStreaming} />
)}
{content && (
<div
onClick={handleContentClick}
dangerouslySetInnerHTML={{
__html: chatSanitizer.sanitize(
markdownToHtml(content) as string,
{ ADD_ATTR: ["target", "rel"] },
),
}}
/>
)}
{isStreaming && (
<>
{!content && (
<span className={classes.processingIndicator}>
<IconLoader2 size={16} className={classes.processingSpinner} />
Thinking
</span>
)}
<span className={classes.streamingCursor} />
</>
)}
</div>
{!isStreaming && message.content && (
<div className={classes.messageActions}>
<CopyTextButton text={message?.content} />
</div>
)}
</div>
);
}
@@ -0,0 +1,56 @@
import { useState } from "react";
import {
IconChevronRight,
IconChevronDown,
IconLoader2,
} from "@tabler/icons-react";
import type { AiChatToolCall } from "../types/ai-chat.types";
import ChatToolResult, { TOOL_LABELS } from "./chat-tool-result";
import classes from "../styles/chat-message.module.css";
type Props = {
toolCalls: AiChatToolCall[];
isStreaming?: boolean;
};
export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
const [expanded, setExpanded] = useState(false);
if (!toolCalls || toolCalls.length === 0) return null;
const activeCall =
isStreaming && toolCalls.length > 0
? [...toolCalls].reverse().find((tc) => tc.result === undefined)
: undefined;
const activeLabel = activeCall
? TOOL_LABELS[activeCall.name] || activeCall.name
: null;
return (
<div className={classes.toolGroup}>
<div
className={classes.toolGroupHeader}
onClick={() => setExpanded((prev) => !prev)}
>
{activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} />
) : expanded ? (
<IconChevronDown size={12} />
) : (
<IconChevronRight size={12} />
)}
<span className={classes.toolGroupLabel}>
{activeLabel ? `${activeLabel}` : `Steps ${toolCalls.length}`}
</span>
</div>
{expanded && (
<div className={classes.toolGroupSteps}>
{toolCalls.map((tc) => (
<ChatToolResult key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,49 @@
import { useState } from "react";
import { IconChevronRight, IconChevronDown } from "@tabler/icons-react";
import type { AiChatToolCall } from "../types/ai-chat.types";
import classes from "../styles/chat-message.module.css";
export const TOOL_LABELS: Record<string, string> = {
list_spaces: "Listed spaces",
search_pages: "Searched pages",
get_page: "Read page",
create_page: "Created page",
update_page: "Updated page",
};
type Props = {
toolCall: AiChatToolCall;
};
export default function ChatToolResult({ toolCall }: Props) {
const [expanded, setExpanded] = useState(false);
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
return (
<div className={classes.toolStep}>
<div
className={classes.toolStepRow}
onClick={() => setExpanded((prev) => !prev)}
>
<span className={classes.toolStepBullet}>·</span>
{expanded ? (
<IconChevronDown size={12} />
) : (
<IconChevronRight size={12} />
)}
<span>{label}</span>
</div>
{expanded && (
<div className={classes.toolStepDetails}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{JSON.stringify(
{ args: toolCall.args, result: toolCall.result },
null,
2,
)}
</pre>
</div>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiChat() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Group gap="xs" align="center">
<Text size="md">{t("AI Chat")}</Text>
<Badge color="gray" variant="light" size="sm" radius="sm">
{t("Beta")}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{t(
"Enable AI Chat to allow users to have multi-turn conversations with AI about your workspace content.",
)}
</Text>
</div>
<AiChatToggle />
</Group>
);
}
function AiChatToggle() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.chat);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiChat: value } as any);
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI Chat")}
/>
</Tooltip>
);
}
@@ -0,0 +1,227 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { sendChatMessage } from "../services/ai-chat-service";
import type {
AiChatMessage,
AiChatStreamEvent,
AiChatToolCall,
ChatAttachment,
PageMention,
} from "../types/ai-chat.types";
type ChatStreamOptions = {
onChatCreated?: (chatId: string) => void;
};
export function useChatStream(
chatId: string | undefined,
options?: ChatStreamOptions,
) {
const [messages, setMessages] = useState<AiChatMessage[]>([]);
const [streamingContent, setStreamingContent] = useState("");
const [streamingToolCalls, setStreamingToolCalls] = useState<AiChatToolCall[]>(
[],
);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [isRetryable, setIsRetryable] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const queryClient = useQueryClient();
const navigate = useNavigate();
const currentChatIdRef = useRef(chatId);
currentChatIdRef.current = chatId;
// Tracks which chatId the local `messages` state currently represents.
// Set when we seed from a server fetch AND when we optimistically own a
// freshly-created chat after `chat_created`. This is the single authority
// marker that keeps server-state effects from clobbering in-flight streams.
const hydratedChatIdRef = useRef<string | undefined>(undefined);
// Reset local state when the consumer switches to a different chat.
// Skip the reset if the new chatId is one the hook itself already claimed
// during a new-chat flow — in that case our optimistic state is the truth.
useEffect(() => {
if (chatId && chatId === hydratedChatIdRef.current) return;
hydratedChatIdRef.current = undefined;
setMessages([]);
setError(null);
setErrorCode(null);
setIsRetryable(false);
}, [chatId]);
const hydrateFromServer = useCallback((msgs: AiChatMessage[]) => {
const forId = currentChatIdRef.current;
if (!forId) return;
if (hydratedChatIdRef.current === forId) return;
hydratedChatIdRef.current = forId;
setMessages(msgs);
}, []);
const sendMessage = useCallback(
(content: string, mentions: PageMention[] = [], attachments: ChatAttachment[] = [], contextPageId?: string) => {
if (isStreaming || (!content.trim() && attachments.length === 0)) return;
setError(null);
setErrorCode(null);
setIsRetryable(false);
setIsStreaming(true);
setStreamingContent("");
setStreamingToolCalls([]);
const metadata: Record<string, unknown> = {};
if (mentions.length) {
metadata.mentionedPageIds = mentions.map((m) => m.id);
}
if (attachments.length) {
metadata.attachments = attachments.map((a) => ({
id: a.id,
fileName: a.fileName,
fileExt: a.fileExt,
}));
}
const userMessage: AiChatMessage = {
id: `temp-${Date.now()}`,
chatId: currentChatIdRef.current || "",
role: "user",
content,
toolCalls: null,
metadata: Object.keys(metadata).length ? metadata : null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
const attachmentIds = attachments.map((a) => a.id);
const abortController = sendChatMessage(
{
chatId: currentChatIdRef.current,
content,
mentionedPageIds: mentions.map((m) => m.id),
...(contextPageId && { contextPageId }),
...(attachmentIds.length && { attachmentIds }),
},
(event: AiChatStreamEvent) => {
switch (event.type) {
case "chat_created":
currentChatIdRef.current = event.chatId;
// Claim authority over this new chatId so when the consumer's
// prop catches up via navigation/onChatCreated, the reset effect
// sees a match and preserves our optimistic messages.
hydratedChatIdRef.current = event.chatId;
if (options?.onChatCreated) {
options.onChatCreated(event.chatId);
} else {
navigate(`/ai/chat/${event.chatId}`, { replace: true });
}
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
break;
case "content":
setStreamingContent((prev) => prev + event.text);
break;
case "tool_call":
setStreamingToolCalls((prev) => [
...prev,
{
id: event.id,
name: event.name,
args: event.args,
},
]);
break;
case "tool_result":
setStreamingToolCalls((prev) =>
prev.map((tc) =>
tc.id === event.id ? { ...tc, result: event.result } : tc,
),
);
break;
case "done": {
setStreamingContent((currentContent) => {
setStreamingToolCalls((currentToolCalls) => {
const assistantMessage: AiChatMessage = {
id: event.messageId,
chatId: currentChatIdRef.current || "",
role: "assistant",
content: currentContent || null,
toolCalls: currentToolCalls.length
? currentToolCalls
: null,
metadata: event.usage ? { tokenUsage: event.usage } : null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
return [];
});
return "";
});
setIsStreaming(false);
queryClient.invalidateQueries({
queryKey: ["ai-chat", currentChatIdRef.current],
});
break;
}
case "error":
setError(event.message);
setErrorCode(event.code || null);
setIsRetryable(event.retryable || false);
setIsStreaming(false);
break;
}
},
(errorMsg) => {
setError(errorMsg);
setIsStreaming(false);
},
() => {
setIsStreaming(false);
},
);
abortRef.current = abortController;
},
[isStreaming, navigate, queryClient],
);
const stopGeneration = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStreamingContent((currentContent) => {
setStreamingToolCalls((currentToolCalls) => {
if (currentContent || currentToolCalls.length > 0) {
const partialMessage: AiChatMessage = {
id: `stopped-${Date.now()}`,
chatId: currentChatIdRef.current || "",
role: "assistant",
content: currentContent || null,
toolCalls: currentToolCalls.length ? currentToolCalls : null,
metadata: null,
createdAt: new Date().toISOString(),
};
setMessages((prev) => [...prev, partialMessage]);
}
return [];
});
return "";
});
setIsStreaming(false);
}, []);
return {
messages,
streamingContent,
streamingToolCalls,
isStreaming,
error,
errorCode,
isRetryable,
sendMessage,
stopGeneration,
hydrateFromServer,
};
}
@@ -0,0 +1,39 @@
import { useParams } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
import { Button } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import AiChatLayout from "../components/ai-chat-layout";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import classes from "../styles/ai-chat.module.css";
export default function AiChat() {
const { t } = useTranslation();
const { chatId } = useParams<{ chatId: string }>();
return (
<div className={classes.layout}>
<ErrorBoundary
resetKeys={[chatId]}
fallbackRender={({ resetErrorBoundary }) => (
<EmptyState
icon={IconAlertTriangle}
title={t("Failed to load chat. An error occurred.")}
action={
<Button
variant="default"
size="sm"
mt="xs"
onClick={resetErrorBoundary}
>
{t("Try again")}
</Button>
}
/>
)}
>
<AiChatLayout />
</ErrorBoundary>
</div>
);
}
@@ -0,0 +1,61 @@
import {
useQuery,
useMutation,
useQueryClient,
useInfiniteQuery,
} from "@tanstack/react-query";
import {
listChats,
getChatInfo,
deleteChat,
updateChatTitle,
searchChats,
} from "../services/ai-chat-service";
export function useChatsQuery() {
return useInfiniteQuery({
queryKey: ["ai-chats"],
queryFn: ({ pageParam }) =>
listChats({ cursor: pageParam, limit: 30 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
export function useChatInfoQuery(chatId: string | undefined) {
return useQuery({
queryKey: ["ai-chat", chatId],
queryFn: () => getChatInfo(chatId!),
enabled: !!chatId,
});
}
export function useDeleteChatMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (chatId: string) => deleteChat(chatId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
},
});
}
export function useUpdateChatTitleMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
updateChatTitle(chatId, title),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["ai-chats"] });
},
});
}
export function useSearchChatsQuery(query: string) {
return useQuery({
queryKey: ["ai-chats-search", query],
queryFn: () => searchChats(query),
enabled: query.length > 0,
});
}
@@ -0,0 +1,144 @@
import api from "@/lib/api-client.ts";
import type {
AiChat,
AiChatMessage,
AiChatStreamEvent,
ChatAttachment,
} from "../types/ai-chat.types";
import { IPagination } from "@/lib/types.ts";
export async function createChat(): Promise<AiChat> {
const req = await api.post<AiChat>("/ai/chats/create");
return req.data;
}
export async function listChats(params?: {
limit?: number;
cursor?: string;
}): Promise<IPagination<AiChat>> {
const req = await api.post("/ai/chats", params);
return req.data;
}
export async function getChatInfo(
chatId: string,
): Promise<{ chat: AiChat; messages: AiChatMessage[] }> {
const req = await api.post("/ai/chats/info", { chatId });
return req.data;
}
export async function deleteChat(chatId: string): Promise<void> {
await api.post("/ai/chats/delete", { chatId });
}
export async function updateChatTitle(
chatId: string,
title: string,
): Promise<void> {
await api.post("/ai/chats/update", { chatId, title });
}
export async function searchChats(query: string): Promise<AiChat[]> {
const req = await api.post("/ai/chats/search", { query });
return req.data;
}
export async function uploadChatFile(
file: File,
chatId?: string,
): Promise<ChatAttachment> {
const formData = new FormData();
formData.append("file", file);
if (chatId) {
formData.append("chatId", chatId);
}
return await api.post("/ai/chats/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
}
export function sendChatMessage(
params: {
chatId?: string;
content: string;
mentionedPageIds?: string[];
contextPageId?: string;
attachmentIds?: string[];
},
onEvent: (event: AiChatStreamEvent) => void,
onError?: (error: string) => void,
onComplete?: () => void,
): AbortController {
const abortController = new AbortController();
(async () => {
try {
const response = await fetch("/api/ai/chats/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
signal: abortController.signal,
credentials: "include",
});
if (!response.ok) {
const errorBody = await response.text();
let errorMessage = `HTTP error ${response.status}`;
try {
const parsed = JSON.parse(errorBody);
errorMessage = parsed.message || errorMessage;
} catch {
// use default
}
onError?.(errorMessage);
return;
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
onError?.("Response body is not readable");
return;
}
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
onComplete?.();
return;
}
try {
const parsed = JSON.parse(data) as AiChatStreamEvent;
onEvent(parsed);
} catch {
// Skip invalid JSON
}
}
}
}
} finally {
reader.releaseLock();
}
onComplete?.();
} catch (error: any) {
if (error.name !== "AbortError") {
onError?.(error.message);
}
}
})();
return abortController;
}
@@ -0,0 +1,169 @@
.layout {
display: flex;
height: 100%;
width: 100%;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
height: calc(100vh - 45px - 2 * var(--mantine-spacing-md));
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.messageListWrapper {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
width: 100%;
}
.messageList {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-md) var(--mantine-spacing-lg);
}
.messageErrorFallback {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
margin-bottom: var(--mantine-spacing-lg);
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
}
.scrollToBottomButton {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
cursor: pointer;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
z-index: 2;
}
.scrollToBottomButton:hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5));
border-color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
}
.scrollToBottomButton:active {
transform: translateX(-50%) scale(0.95);
}
.inputArea {
padding: var(--mantine-spacing-xs) var(--mantine-spacing-lg) var(--mantine-spacing-lg);
}
/* Empty state - Notion AI style centered layout */
.emptyState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl) var(--mantine-spacing-lg);
}
.emptyStateIcon {
width: 48px;
height: 48px;
margin-bottom: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.emptyStateBrand {
font-size: var(--mantine-font-size-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-bottom: var(--mantine-spacing-xs);
}
.emptyStateTitle {
font-size: 1.5rem;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
margin-bottom: var(--mantine-spacing-xl);
text-align: center;
}
.emptyStateInput {
width: 100%;
max-width: 600px;
margin-bottom: var(--mantine-spacing-xl);
padding: 6px 0;
}
.suggestionsSection {
width: 100%;
max-width: 600px;
}
.suggestionsLabel {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--mantine-spacing-sm);
}
.suggestionsGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--mantine-spacing-sm);
}
.suggestionCard {
display: flex;
align-items: flex-start;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
cursor: pointer;
background: transparent;
transition: background-color 150ms, border-color 150ms;
text-align: left;
width: 100%;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.suggestionIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
margin-top: 1px;
}
.suggestionText {
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
line-height: 1.4;
}
@@ -0,0 +1,139 @@
.panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 0 0 var(--mantine-spacing-sm) 0;
border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.toolbarSpacer {
flex: 1;
}
.titleButton {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
font-weight: 500;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
max-width: 60%;
min-width: 0;
}
.titleButton:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
}
.titleText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--mantine-spacing-sm) 0;
scroll-behavior: smooth;
}
.inputArea {
padding-top: var(--mantine-spacing-sm);
}
.emptyState {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-md);
padding: var(--mantine-spacing-xl) var(--mantine-spacing-sm);
}
.emptyStateIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.emptyStateTitle {
font-size: var(--mantine-font-size-lg);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
text-align: center;
}
.quickActions {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.quickAction {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-radius-md);
cursor: pointer;
background: transparent;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
text-align: left;
width: 100%;
transition: background-color 150ms, border-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.quickActionIcon {
flex-shrink: 0;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.historyList {
max-height: 300px;
overflow-y: auto;
}
.historyItem {
display: flex;
align-items: center;
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
cursor: pointer;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
transition: background-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
&[data-active] {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.historyItemTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,242 @@
.inputWrapper {
position: relative;
overflow: hidden;
border: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 16px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: light-dark(
0 2px 40px 4px rgba(0, 0, 0, 0.07),
0 2px 40px 4px rgba(0, 0, 0, 0.5)
);
transition:
border-color 150ms,
box-shadow 150ms;
&:focus-within {
border-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-4)
);
box-shadow: light-dark(
0 4px 48px 6px rgba(0, 0, 0, 0.09),
0 4px 48px 6px rgba(0, 0, 0, 0.6)
);
}
}
.inputWrapperFlat {
position: relative;
overflow: hidden;
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: 12px;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
box-shadow: none;
transition: border-color 150ms;
&:focus-within {
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
}
.disclaimer {
margin-top: 6px;
text-align: center;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.attachmentChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 14px 0;
}
.attachmentChip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: 8px;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-xs);
max-width: 200px;
}
.attachmentChipUploading {
opacity: 0.55;
}
.attachmentChipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachmentChipRemove {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
padding: 0;
margin-left: 2px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
border-radius: 50%;
@mixin hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
}
.editorContent {
overflow: hidden;
:global(.ProseMirror) {
outline: none;
border: none;
background-color: transparent;
padding: 14px 18px 8px;
font-size: 15px;
line-height: 1.6;
max-height: 200px;
overflow-y: auto;
min-height: 24px;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
:global(.ProseMirror p) {
margin-block-start: 0;
margin-block-end: 0;
}
:global(.ProseMirror p.is-editor-empty:first-child::before) {
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 4px 12px 10px;
gap: var(--mantine-spacing-xs);
}
.sendButton {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: background-color 150ms, opacity 150ms;
background: light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-9));
&:disabled {
opacity: 0.25;
cursor: default;
}
@mixin hover {
&:not(:disabled) {
opacity: 0.85;
}
}
}
.attachButton {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
cursor: pointer;
padding: 2px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms;
@mixin hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
}
.plusButton {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
background: none;
cursor: pointer;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
transition: color 150ms, background-color 150ms;
@mixin hover {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
}
.plusMenuItem {
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm);
border: none;
background: none;
cursor: pointer;
width: 100%;
font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
border-radius: var(--mantine-radius-sm);
transition: background-color 150ms;
@mixin hover {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
background: none;
}
}
.plusMenuIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
}
.stopButton {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
cursor: pointer;
transition: background-color 150ms;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
@mixin hover {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@@ -0,0 +1,286 @@
.message {
margin-bottom: var(--mantine-spacing-lg);
}
.userMessage {
composes: message;
display: flex;
justify-content: flex-end;
}
.userBubble {
max-width: 75%;
padding: 10px 16px;
border-radius: 18px;
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
font-size: 15px;
line-height: 1.6;
word-wrap: break-word;
overflow-wrap: break-word;
}
[data-aside-chat] .userBubble {
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
.userBubble p {
margin: 0;
}
.messageAttachments {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.messageAttachmentChip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 6px;
background: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
font-size: var(--mantine-font-size-xs);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistantMessage {
composes: message;
}
.messageContent {
font-size: 15px;
line-height: 1.7;
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-1));
word-wrap: break-word;
overflow-wrap: break-word;
}
.messageContent p {
margin: 0 0 0.75em 0;
}
.messageContent p:last-child {
margin-bottom: 0;
}
.messageContent ul,
.messageContent ol {
margin: 0.5em 0 0.75em 0;
padding-left: 1.5em;
}
.messageContent li {
margin-bottom: 0.3em;
}
.messageContent h1,
.messageContent h2,
.messageContent h3 {
margin: 1em 0 0.5em 0;
font-weight: 600;
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
}
.messageContent h1 {
font-size: 1.4em;
}
.messageContent h2 {
font-size: 1.2em;
}
.messageContent h3 {
font-size: 1.05em;
}
.messageContent pre {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
overflow-x: auto;
font-size: var(--mantine-font-size-sm);
margin: 0.75em 0;
}
.messageContent code {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
padding: 2px 6px;
border-radius: 4px;
font-size: 0.88em;
}
.messageContent pre code {
background: none;
padding: 0;
}
.messageContent blockquote {
border-left: 3px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
padding-left: var(--mantine-spacing-md);
margin: 0.75em 0;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-2));
}
.messageContent a {
color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4));
text-decoration: none;
@mixin hover {
text-decoration: underline;
}
}
.messageContent a[href^="/s/"],
.messageContent a[href^="/p/"] {
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1));
font-weight: 500;
text-decoration: none;
cursor: pointer;
@mixin light {
border-bottom: 0.05em solid var(--mantine-color-dark-0);
}
@mixin dark {
border-bottom: 0.05em solid var(--mantine-color-dark-2);
}
@mixin hover {
text-decoration: none;
@mixin light {
border-bottom-color: var(--mantine-color-dark-2);
}
@mixin dark {
border-bottom-color: var(--mantine-color-dark-0);
}
}
}
.messageContent hr {
border: none;
border-top: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
margin: 1em 0;
}
.toolGroup {
margin: 6px 0;
font-size: var(--mantine-font-size-xs);
}
.toolGroupHeader {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.4;
transition: color 120ms ease;
}
.toolGroupHeader:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolGroupLabel {
font-weight: 500;
}
.toolGroupSteps {
margin-top: 4px;
padding-left: 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.toolStep {
font-size: var(--mantine-font-size-xs);
}
.toolStepRow {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
line-height: 1.5;
transition: color 120ms ease;
}
.toolStepRow:hover {
color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
}
.toolStepBullet {
display: inline-block;
width: 8px;
text-align: center;
opacity: 0.6;
}
.toolStepDetails {
margin-top: 4px;
margin-left: 18px;
padding: 6px 10px;
border-radius: var(--mantine-radius-sm);
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
}
.messageActions {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
}
.processingIndicator {
display: inline-flex;
align-items: center;
gap: 6px;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: var(--mantine-font-size-sm);
}
.processingSpinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.streamingCursor {
display: inline-block;
width: 2px;
height: 1em;
background: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
animation: blink 1s step-end infinite;
vertical-align: text-bottom;
margin-left: 1px;
}
@keyframes blink {
50% {
opacity: 0;
}
}
@@ -0,0 +1,138 @@
.sidebar {
height: 100%;
width: 100%;
padding: var(--mantine-spacing-md);
display: flex;
flex-direction: column;
gap: var(--mantine-spacing-xs);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--mantine-spacing-xs);
}
.title {
font-weight: 600;
font-size: var(--mantine-font-size-sm);
}
.searchInput {
margin-bottom: var(--mantine-spacing-xs);
}
.chatList {
flex: 1;
overflow-y: auto;
}
.chatGroup + .chatGroup {
margin-top: var(--mantine-spacing-sm);
}
.chatGroupLabel {
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
user-select: none;
}
.chatListEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--mantine-spacing-xl) var(--mantine-spacing-md);
text-align: center;
gap: 4px;
user-select: none;
}
.chatListEmptyIcon {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
margin-bottom: var(--mantine-spacing-xs);
}
.chatListEmptyTitle {
font-size: var(--mantine-font-size-sm);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
}
.chatListEmptyHint {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3));
line-height: 1.4;
}
.chatItem {
display: flex;
align-items: center;
padding: 8px var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
cursor: pointer;
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
font-size: var(--mantine-font-size-sm);
user-select: none;
gap: var(--mantine-spacing-xs);
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-6)
);
}
&[data-active] {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-6)
);
}
}
.chatItemTitle {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chatItemDate {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
white-space: nowrap;
transition: opacity 150ms;
}
.chatItemRenameInput {
font-size: var(--mantine-font-size-sm);
padding: 0;
height: auto;
min-height: 0;
background: transparent;
color: inherit;
}
.chatItem:hover .chatItemDate {
opacity: 0;
}
.chatItemActions {
position: absolute;
right: var(--mantine-spacing-xs);
opacity: 0;
transition: opacity 150ms;
}
.chatItem {
position: relative;
}
.chatItem:hover .chatItemActions {
opacity: 1;
}
@@ -0,0 +1,49 @@
export type AiChat = {
id: string;
workspaceId: string;
creatorId: string;
title: string | null;
createdAt: string;
updatedAt: string;
};
export type AiChatToolCall = {
id: string;
name: string;
args: Record<string, unknown>;
result?: unknown;
};
export type AiChatMessage = {
id: string;
chatId: string;
role: 'user' | 'assistant' | 'tool';
content: string | null;
toolCalls: AiChatToolCall[] | null;
metadata: Record<string, unknown> | null;
createdAt: string;
};
export type AiChatStreamEvent =
| { type: 'chat_created'; chatId: string }
| { type: 'content'; text: string }
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
| { type: 'tool_result'; id: string; result: unknown }
| { type: 'done'; messageId: string; usage?: Record<string, number> }
| { type: 'error'; message: string; code?: string; retryable?: boolean };
export type PageMention = {
id: string;
title: string;
slugId: string;
spaceSlug?: string;
icon?: string;
};
export type ChatAttachment = {
id: string;
fileName: string;
fileExt: string;
fileSize: number;
mimeType: string;
};
@@ -1,12 +1,13 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableAiSearch() {
const { t } = useTranslation();
@@ -37,9 +38,8 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -56,14 +56,16 @@ export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
</Tooltip>
);
}
@@ -1,17 +1,20 @@
import { Group, Text, Switch } from "@mantine/core";
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
export default function EnableGenerativeAi() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useIsCloudEE();
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -38,11 +41,13 @@ export default function EnableGenerativeAi() {
</Text>
</div>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Tooltip>
</Group>
);
}
@@ -13,10 +13,12 @@ import {
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
@@ -25,7 +27,8 @@ export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcp);
const hasAccess = useIsCloudEE();
const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const mcpUrl = `${getAppUrl()}/mcp`;
@@ -46,11 +49,7 @@ export default function McpSettings() {
return (
<Stack gap="lg">
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
>
<Alert icon={<IconInfoCircle />} title={upgradeLabel} color="blue">
{t(
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
@@ -64,23 +63,22 @@ export default function McpSettings() {
{t(
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
)}{" "}
{t("View the")}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
target="_blank"
size="sm"
>
{t("MCP documentation")}
</Anchor>
.
<Trans
i18nKey="View the <anchor>MCP documentation</anchor>."
components={{
anchor: <Anchor href="https://docmost.com/docs/user-guide/mcp" target="_blank" size="sm" />,
}}
/>
</Text>
</div>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Tooltip>
</Group>
{checked && (
@@ -89,11 +87,7 @@ export default function McpSettings() {
{t("MCP Server URL")}
</Text>
<Group gap="xs">
<TextInput
value={mcpUrl}
readOnly
style={{ flex: 1 }}
/>
<TextInput value={mcpUrl} readOnly style={{ flex: 1 }} />
<CopyButton value={mcpUrl} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
@@ -123,12 +117,36 @@ export default function McpSettings() {
{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.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>
+8 -3
View File
@@ -6,17 +6,21 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import EnableAiChat from "@/ee/ai-chat/components/enable-ai-chat.tsx";
import McpSettings from "@/ee/ai/components/mcp-settings.tsx";
import { Alert, Stack, Tabs } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { isCloud } from "@/lib/config.ts";
import { useLocation, useNavigate } from "react-router-dom";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasAccess = useIsCloudEE();
const hasAccess = useHasFeature(Feature.AI);
const upgradeLabel = useUpgradeLabel();
const location = useLocation();
const navigate = useNavigate();
@@ -55,7 +59,7 @@ export default function AiSettings() {
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
title={upgradeLabel}
color="blue"
mb="lg"
>
@@ -68,6 +72,7 @@ export default function AiSettings() {
<Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
<EnableAiChat />
</Stack>
</Tabs.Panel>
@@ -5,12 +5,14 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function RestrictApiToAdmins() {
const { t } = useTranslation();
@@ -18,7 +20,8 @@ export default function RestrictApiToAdmins() {
const [checked, setChecked] = useState(
workspace?.settings?.api?.restrictToAdmins === true,
);
const hasAccess = useEnterpriseAccess();
const hasAccess = useHasFeature(Feature.API_KEYS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -51,7 +54,7 @@ export default function RestrictApiToAdmins() {
<ResponsiveSettingsControl>
<Tooltip
label={t("Requires an enterprise license")}
label={upgradeLabel}
disabled={hasAccess}
refProp="rootRef"
>
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName, getAppUrl } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -58,11 +58,12 @@ export default function UserApiKeys() {
<SettingsTitle title={t("API keys")} />
<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.")}
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{mcpEnabled && canCreate && (
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Anchor, Button, Divider, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
@@ -56,12 +56,12 @@ export default function WorkspaceApiKeys() {
<SettingsTitle title={t("API management")} />
<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.")}
<Trans
i18nKey="Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
<RestrictApiToAdmins />
@@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data;
}
export async function findWorkspacesByEmail(email: string): Promise<void> {
await api.post("/workspace/find-by-email", { email });
}
export async function verifyEmail(data: { token: string }): Promise<void> {
await api.post("/workspace/verify-email", data);
}
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
await api.post("/workspace/resend-verification", data);
}
@@ -20,14 +20,22 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }),
});
const findWorkspaceSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
});
export function CloudLoginForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
@@ -37,6 +45,13 @@ export function CloudLoginForm() {
},
});
const findForm = useForm<any>({
validate: zod4Resolver(findWorkspaceSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: { hostname: string }) {
setIsLoading(true);
@@ -54,8 +69,21 @@ export function CloudLoginForm() {
setIsLoading(false);
}
async function onFindSubmit(data: { email: string }) {
setIsFindLoading(true);
try {
await findWorkspacesByEmail(data.email);
setFindEmailSent(true);
} catch {
findForm.setFieldError("email", "An error occurred. Please try again.");
}
setIsFindLoading(false);
}
return (
<div>
<AuthLayout>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
@@ -83,15 +111,47 @@ export function CloudLoginForm() {
{t("Continue")}
</Button>
</form>
<Divider my="lg" label="or" labelPosition="center" />
{findEmailSent ? (
<Text ta="center" size="sm" c="dimmed">
{t("We've sent you an email with your associated workspaces.")}
</Text>
) : (
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
<Text fw={600} mb="xs">
{t("Find your workspaces")}
</Text>
<TextInput
type="email"
placeholder="name@company.com"
description={t(
"We'll send a list of your workspaces to this email.",
)}
withErrorStyles={false}
{...findForm.getInputProps("email")}
/>
<Button
type="submit"
fullWidth
mt="md"
variant="light"
loading={isFindLoading}
>
{t("Send")}
</Button>
</form>
)}
</Box>
</Container>
<Text ta="center">
<Text ta="center" mb="xl">
{t("Don't have a workspace?")}{" "}
<Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}>
{t("Create new workspace")}
</Anchor>
</Text>
</div>
</AuthLayout>
);
}
+1 -2
View File
@@ -6,7 +6,6 @@ import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() {
@@ -57,7 +56,7 @@ export default function SsoLogin() {
/>
)}
{(isCloud() || data.hasLicenseKey) && (
{data.authProviders.length > 0 && (
<>
<Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => (
@@ -0,0 +1,7 @@
import { atomWithStorage } from "jotai/utils";
import type { Entitlements } from "./entitlement.types";
export const entitlementAtom = atomWithStorage<Entitlements | null>(
"entitlements",
null,
);
@@ -0,0 +1,7 @@
import api from "@/lib/api-client";
import { Entitlements } from "./entitlement.types";
export async function getEntitlements(): Promise<Entitlements> {
const req = await api.post<Entitlements>("/workspace/entitlements");
return req.data as Entitlements;
}
@@ -0,0 +1,7 @@
export type Tier = "free" | "standard" | "business" | "enterprise";
export type Entitlements = {
cloud: boolean;
tier: Tier;
features: string[];
};
@@ -0,0 +1,11 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getEntitlements } from "./entitlement-service";
import { Entitlements } from "./entitlement.types";
export function useEntitlements(): UseQueryResult<Entitlements> {
return useQuery({
queryKey: ["entitlements"],
queryFn: getEntitlements,
staleTime: 5 * 60 * 1000,
});
}
+20
View File
@@ -0,0 +1,20 @@
export const Feature = {
SSO_CUSTOM: 'sso:custom',
SSO_GOOGLE: 'sso:google',
MFA: 'mfa',
API_KEYS: 'api:keys',
COMMENT_RESOLUTION: 'comment:resolution',
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
@@ -1,12 +0,0 @@
import { isCloud } from "@/lib/config";
import useLicense from "@/ee/hooks/use-license";
import usePlan from "@/ee/hooks/use-plan";
const useEnterpriseAccess = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || (!isCloud() && hasLicenseKey);
};
export default useEnterpriseAccess;
+7
View File
@@ -0,0 +1,7 @@
import { useAtom } from "jotai";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export const useHasFeature = (feature: string): boolean => {
const [entitlements] = useAtom(entitlementAtom);
return entitlements?.features?.includes(feature) ?? false;
};
-9
View File
@@ -1,9 +0,0 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export const useLicense = () => {
const [currentUser] = useAtom(currentUserAtom);
return { hasLicenseKey: currentUser?.workspace?.hasLicenseKey };
};
export default useLicense;
@@ -0,0 +1,16 @@
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import { isCloud } from "@/lib/config";
export function useUpgradeLabel(): string {
const { t } = useTranslation();
const [entitlements] = useAtom(entitlementAtom);
if (!isCloud()) {
return entitlements != null && entitlements.tier !== "free"
? t("Upgrade your license tier.")
: t("Available with a paid license");
}
return t("Upgrade your plan");
}
@@ -1,27 +1,28 @@
import { z } from "zod/v4";
import React from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core";
import React, { useRef } from "react";
import { Button, Divider, Group, Modal, Stack, Textarea } from "@mantine/core";
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";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
import RemoveLicense from "@/ee/licence/components/remove-license.tsx";
export default function ActivateLicense() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom);
const [entitlements] = useAtom(entitlementAtom);
const hasLicense = entitlements != null && entitlements.tier !== "free";
return (
<Group justify="flex-end" wrap="nowrap" mb="sm">
<Button onClick={open}>
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
{hasLicense ? t("Update license") : t("Add license")}
</Button>
{workspace?.hasLicenseKey && <RemoveLicense />}
{hasLicense && <RemoveLicense />}
<Modal
size="550"
@@ -48,6 +49,7 @@ interface ActivateLicenseFormProps {
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const { t } = useTranslation();
const activateLicenseMutation = useActivateMutation();
const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
@@ -59,32 +61,71 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset();
onClose();
onClose?.();
}
function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = (e.target?.result as string)?.trim();
if (content) {
form.setFieldValue("licenseKey", content);
handleSubmit({ licenseKey: content });
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Textarea
label={t("License key")}
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one."
placeholder={t("e.g eyJhb.....")}
variant="filled"
autosize
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
<input
type="file"
accept=".txt"
ref={fileInputRef}
onChange={handleFileUpload}
hidden
/>
<Group justify="flex-end" mt="md">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
<Stack gap="xs">
<Textarea
label={t("License key")}
placeholder={t("e.g eyJhb.....")}
variant="filled"
autosize
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
/>
<Group justify="flex-end">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
<Divider label={t("Or")} labelPosition="center" />
<Group justify="center">
<Button
variant="light"
onClick={() => fileInputRef.current?.click()}
>
{t("Upload license file")}
</Button>
</Group>
</Stack>
</form>
);
}
@@ -31,7 +31,8 @@ export default function LicenseDetails() {
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
Enterprise {license.trial && <Badge color="green">Trial</Badge>}
{license.licenseType === "business" ? "Business" : "Enterprise"}{" "}
{license.trial && <Badge color="green">Trial</Badge>}
</Table.Td>
</Table.Tr>
@@ -68,7 +68,11 @@ export default function OssDetails() {
</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.
Get an enterprise trial key at <a href="https://customers.docmost.com/" target="_blank" rel="noopener noreferrer">customers.docmost.com</a>.
</Text>
<Text size="sm" c="dimmed">
Visit <a href="https://docmost.com/pricing" target="_blank" rel="noopener noreferrer">docmost.com/pricing</a> to purchase an enterprise license.
</Text>
</Stack>
</Stack>
+4 -3
View File
@@ -8,10 +8,11 @@ import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.
import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { entitlementAtom } from "@/ee/entitlement/entitlement-atom";
export default function License() {
const [workspace] = useAtom(workspaceAtom);
const [entitlements] = useAtom(entitlementAtom);
const hasLicense = entitlements != null && entitlements.tier !== "free";
const { isAdmin } = useUserRole();
if (!isAdmin) {
@@ -29,7 +30,7 @@ export default function License() {
<InstallationDetails />
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
{hasLicense ? <LicenseDetails /> : <OssDetails />}
</>
);
}
@@ -31,6 +31,7 @@ export function useActivateMutation() {
queryKey: ["license"],
});
queryClient.refetchQueries({ queryKey: ["currentUser"] });
queryClient.refetchQueries({ queryKey: ["entitlements"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
@@ -47,6 +48,7 @@ export function useRemoveLicenseMutation() {
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["license"] });
queryClient.refetchQueries({ queryKey: ["currentUser"] });
queryClient.refetchQueries({ queryKey: ["entitlements"] });
},
});
}
@@ -1,7 +1,10 @@
export type LicenseType = 'business' | 'enterprise';
export interface ILicenseInfo {
id: string;
customerName: string;
seatCount: number;
licenseType: LicenseType;
issuedAt: Date;
expiresAt: Date;
trial: boolean;
@@ -22,6 +22,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
import { MfaBackupCodeInput } from "./mfa-backup-code-input";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
const formSchema = z.object({
code: z
@@ -66,6 +67,7 @@ export function MfaChallenge() {
};
return (
<AuthLayout>
<Container size={420} className={classes.container}>
<Paper radius="lg" p={40} className={classes.paper}>
<Stack align="center" gap="xl">
@@ -157,5 +159,6 @@ export function MfaChallenge() {
</Stack>
</Paper>
</Container>
</AuthLayout>
);
}
@@ -7,8 +7,9 @@ import { getMfaStatus } from "@/ee/mfa";
import { MfaSetupModal } from "@/ee/mfa";
import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
export function MfaSettings() {
@@ -17,7 +18,8 @@ export function MfaSettings() {
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
const { hasLicenseKey } = useLicense();
const canUseMfa = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"],
@@ -28,8 +30,6 @@ export function MfaSettings() {
return null;
}
const canUseMfa = isCloud() || hasLicenseKey;
// Check if MFA is truly enabled
const isMfaEnabled = mfaStatus?.isEnabled === true;
@@ -69,7 +69,7 @@ export function MfaSettings() {
<ResponsiveSettingsControl>
{!isMfaEnabled ? (
<Tooltip
label={t("Available in enterprise edition")}
label={upgradeLabel}
disabled={canUseMfa}
>
<Button
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { MfaSetupModal } from "@/ee/mfa";
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { useNavigate } from "react-router-dom";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
export default function MfaSetupRequired() {
const { t } = useTranslation();
@@ -15,6 +16,7 @@ export default function MfaSetupRequired() {
};
return (
<AuthLayout>
<Container size="sm" py="xl">
<Paper shadow="sm" p="xl" radius="md" withBorder>
<Stack>
@@ -44,5 +46,6 @@ export default function MfaSetupRequired() {
</Stack>
</Paper>
</Container>
</AuthLayout>
);
}
@@ -19,7 +19,8 @@ import { usePageRestrictionInfoQuery } from "@/ee/page-permission/queries/page-p
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 { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import { useSpaceQuery } from "@/features/space/queries/space-query";
@@ -33,9 +34,9 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const { pageSlug, spaceSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const [opened, { open, close }] = useDisclosure(false);
const isCloudEE = useIsCloudEE();
const hasPagePermissions = useHasFeature(Feature.PAGE_PERMISSIONS);
const [activeTab, setActiveTab] = useState<string | null>(
isCloudEE ? "access" : "publish",
hasPagePermissions ? "access" : "publish",
);
const [workspace] = useAtom(workspaceAtom);
@@ -51,7 +52,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
const isPubliclyShared = !!share;
const { data: restrictionInfo, isLoading: restrictionLoading } =
usePageRestrictionInfoQuery(opened && isCloudEE ? pageId : undefined);
usePageRestrictionInfoQuery(opened && hasPagePermissions ? pageId : undefined);
return (
<>
@@ -70,7 +71,10 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
) : null
}
variant="default"
onClick={open}
onClick={() => {
setActiveTab(isPubliclyShared ? "publish" : hasPagePermissions ? "access" : "publish");
open();
}}
>
{t("Share")}
</Button>
@@ -92,7 +96,7 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
</Tabs.List>
<Tabs.Panel value="access">
{!isCloudEE ? (
{!hasPagePermissions ? (
<Stack align="center" py="md">
<IconLock size={20} stroke={1.5} />
<Text size="sm" ta="center" fw={500}>
@@ -16,7 +16,8 @@ import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { usePageQuery } from "@/features/page/queries/page-query";
import { usePageVerificationInfoQuery } from "@/ee/page-verification/queries/page-verification-query";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { SetupVerificationForm } from "./setup-verification-form";
import { ManageVerificationForm } from "./manage-verification-form";
import { getStatusColor, getStatusLabel } from "./verification-status";
@@ -82,7 +83,7 @@ export function PageVerificationBadge({
const { t } = useTranslation();
const { pageSlug } = useParams();
const pageSlugId = extractPageSlugId(pageSlug);
const isCloudEE = useIsCloudEE();
const isCloudEE = useHasFeature(Feature.PAGE_VERIFICATION);
const [opened, { open, close }] = useDisclosure(false);
const { data: page } = usePageQuery({ pageId: pageSlugId });
@@ -143,7 +144,7 @@ export function PageVerificationMenuItem({
onClick,
}: PageVerificationMenuItemProps) {
const { t } = useTranslation();
const isCloudEE = useIsCloudEE();
const isCloudEE = useHasFeature(Feature.PAGE_VERIFICATION);
if (!isCloudEE) return null;
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Container, Title, Text, Button, Box } from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import {
verifyEmail,
resendVerificationEmail,
} from "@/ee/cloud/service/cloud-service.ts";
import { notifications } from "@mantine/notifications";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
export default function VerifyEmail() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const rawEmail = searchParams.get("email");
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
const sig = searchParams.get("sig");
const [isResending, setIsResending] = useState(false);
const [resent, setResent] = useState(false);
useEffect(() => {
if (token) {
handleVerify(token);
}
}, [token]);
async function handleVerify(verifyToken: string) {
try {
await verifyEmail({ token: verifyToken });
navigate(APP_ROUTE.HOME);
} catch (err) {
notifications.show({
message: t("Verification failed. The link may have expired."),
color: "red",
});
navigate(APP_ROUTE.AUTH.LOGIN);
}
}
async function handleResend() {
if (!email || !sig) return;
setIsResending(true);
try {
await resendVerificationEmail({ email, sig });
setResent(true);
} catch {
notifications.show({
message: t("Failed to resend verification email. Please try again."),
color: "red",
});
}
setIsResending(false);
}
if (token) {
return (
<AuthLayout>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Verifying your email")}
</Title>
<Text ta="center" c="dimmed">
{t("Please wait...")}
</Text>
</Box>
</Container>
</AuthLayout>
);
}
return (
<AuthLayout>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Check your email")}
</Title>
<Text ta="center" c="dimmed" mb="md">
{email
? t("We sent a verification link to {{email}}.", { email })
: t("We sent a verification link to your email.")}
</Text>
<Text ta="center" size="sm" c="dimmed" mb="lg">
{t("Click the link to verify your email and access your workspace.")}
</Text>
{email && sig && !resent && (
<Button
fullWidth
variant="light"
onClick={handleResend}
loading={isResending}
>
{t("Resend verification email")}
</Button>
)}
{resent && (
<Text ta="center" size="sm" c="dimmed">
{t("Verification email sent. Please check your inbox.")}
</Text>
)}
</Box>
</Container>
</AuthLayout>
);
}
@@ -6,21 +6,23 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import useEnterpriseAccess from "@/ee/hooks/use-enterprise-access.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
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>
<div>
<Text size="md">{t("Disable public sharing")}</Text>
<Text size="sm" c="dimmed">
{t("Prevent members from sharing pages publicly.")}
</Text>
</div>
<DisablePublicSharingToggle />
<DisablePublicSharingToggle />
</Group>
);
}
@@ -31,7 +33,8 @@ function DisablePublicSharingToggle() {
const [checked, setChecked] = useState(
workspace?.settings?.sharing?.disabled === true,
);
const hasAccess = useEnterpriseAccess();
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const applyChange = async (value: boolean) => {
try {
@@ -72,15 +75,11 @@ function DisablePublicSharingToggle() {
};
return (
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
refProp="rootRef"
>
<Tooltip label={upgradeLabel} disabled={hasSharingControls} refProp="rootRef">
<Switch
checked={checked}
onChange={handleChange}
disabled={!hasAccess}
disabled={!hasSharingControls}
aria-label={t("Toggle public sharing")}
/>
</Tooltip>
@@ -1,10 +1,20 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import {
Group,
Text,
Switch,
MantineSize,
Title,
Tooltip,
} from "@mantine/core";
import { useAtom } from "jotai";
import { 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 { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnforceMfa() {
const { t } = useTranslation();
@@ -33,6 +43,8 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceMfa);
const hasAccess = useHasFeature(Feature.MFA);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -49,13 +61,16 @@ export function EnforceMfaToggle({ size, label }: EnforceMfaToggleProps) {
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle MFA enforcement")}
/>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle MFA enforcement")}
/>
</Tooltip>
);
}
@@ -1,10 +1,13 @@
import { Group, Text, Switch, MantineSize } from "@mantine/core";
import { Group, Text, Switch, MantineSize, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import { 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 { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnforceSso() {
const { t } = useTranslation();
@@ -33,6 +36,8 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceSso);
const hasAccess = useHasFeature(Feature.SSO_CUSTOM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -49,13 +54,16 @@ export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle sso enforcement")}
/>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle sso enforcement")}
/>
</Tooltip>
);
}
@@ -6,6 +6,9 @@ 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";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpacePublicSharingToggleProps = {
space: ISpace;
@@ -17,6 +20,9 @@ export default function SpacePublicSharingToggle({
const { t } = useTranslation();
const [workspace] = useAtom(workspaceAtom);
const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasSharingControls || workspaceDisabled;
const [checked, setChecked] = useState(
space.settings?.sharing?.disabled === true,
);
@@ -68,14 +74,14 @@ export default function SpacePublicSharingToggle({
</Text>
</div>
<Tooltip
label={t("Public sharing is disabled at the workspace level")}
disabled={!workspaceDisabled}
label={!hasSharingControls ? upgradeLabel : t("Public sharing is disabled at the workspace level")}
disabled={!isDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={workspaceDisabled}
disabled={isDisabled}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
@@ -0,0 +1,61 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
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";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceViewerCommentsToggleProps = {
space: ISpace;
};
export default function SpaceViewerCommentsToggle({
space,
}: SpaceViewerCommentsToggleProps) {
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
allowViewerComments: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow viewers to comment")}</Text>
<Text size="sm" c="dimmed">
{t("Allow viewers to add comments on pages in this space.")}
</Text>
</div>
<Tooltip
label={upgradeLabel}
disabled={!isDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
</Group>
);
}
@@ -12,13 +12,18 @@ 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";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type RetentionUnit = "days" | "months" | "years";
const DEFAULT_RETENTION_DAYS = 30;
function daysToRetention(days: number): { amount: number; unit: RetentionUnit } {
function daysToRetention(days: number): {
amount: number;
unit: RetentionUnit;
} {
if (days >= 365 && days % 365 === 0) {
return { amount: days / 365, unit: "years" };
}
@@ -36,14 +41,19 @@ function retentionToDays(amount: number, unit: RetentionUnit): number {
export default function TrashRetention() {
const { t } = useTranslation();
const hasAccess = useEnterpriseAccess();
const hasRetention = useHasFeature(Feature.RETENTION);
const upgradeLabel = useUpgradeLabel();
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 [retentionAmount, setRetentionAmount] = useState<number | string>(
parsed.amount,
);
const [retentionUnit, setRetentionUnit] = useState<RetentionUnit>(
parsed.unit,
);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -63,14 +73,17 @@ export default function TrashRetention() {
setSaving(true);
try {
const updatedWorkspace = await updateWorkspace({ trashRetentionDays: days });
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"),
message:
err?.response?.data?.message || t("Failed to update trash retention"),
color: "red",
});
const { amount, unit } = daysToRetention(currentDays);
@@ -81,10 +94,11 @@ export default function TrashRetention() {
}
};
const isDirty = retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit,
) !== currentDays;
const isDirty =
retentionToDays(
typeof retentionAmount === "number" ? retentionAmount : 1,
retentionUnit,
) !== currentDays;
return (
<div>
@@ -93,10 +107,7 @@ export default function TrashRetention() {
{t("Pages in trash will be permanently deleted after this period.")}
</Text>
<Tooltip
label={t("Requires an enterprise license")}
disabled={hasAccess}
>
<Tooltip label={upgradeLabel} disabled={hasRetention}>
<Group gap="xs" wrap="nowrap" maw={320}>
<NumberInput
value={retentionAmount}
@@ -105,7 +116,7 @@ export default function TrashRetention() {
hideControls
size="sm"
w={60}
disabled={!hasAccess}
disabled={!hasRetention}
/>
<Select
data={[
@@ -121,13 +132,13 @@ export default function TrashRetention() {
}}
size="sm"
style={{ flex: 1 }}
disabled={!hasAccess}
disabled={!hasRetention}
/>
<Button
size="sm"
onClick={handleSave}
loading={saving}
disabled={!hasAccess || !isDirty}
disabled={!hasRetention || !isDirty}
>
{t("Save")}
</Button>
+13 -24
View File
@@ -12,14 +12,15 @@ import { useTranslation } from "react-i18next";
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";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasEnterpriseAccess = useEnterpriseAccess();
const isCloudEE = useIsCloudEE();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasRetention = useHasFeature(Feature.RETENTION);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
if (!isAdmin) {
return null;
@@ -36,39 +37,27 @@ export default function Security() {
<Divider my="lg" />
{(!isCloud() || hasEnterpriseAccess) && (
<>
<DisablePublicSharing />
<Divider my="lg" />
</>
)}
<DisablePublicSharing />
<Divider my="lg" />
{!isCloud() && (
<>
<TrashRetention />
<Divider my="lg" />
</>
)}
<TrashRetention />
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
{hasEnterpriseAccess && (
<>
<EnforceSso />
<Divider my="lg" />
</>
)}
<EnforceSso />
<Divider my="lg" />
{isCloudEE && (
{(isCloud() || hasCustomSso) && (
<>
<AllowedDomains />
<Divider my="lg" />
</>
)}
{hasEnterpriseAccess && (
{hasCustomSso && (
<>
<CreateSsoProvider />
<Divider size={0} my="lg" />