Compare commits

..
263 changed files with 8633 additions and 11967 deletions
+11
View File
@@ -51,6 +51,17 @@ DRAWIO_URL=
# Gotenberg URL for server-side PDF export
GOTENBERG_URL=
# Integration OAuth apps (create one per provider you enable)
INTEGRATION_GITHUB_CLIENT_ID=
INTEGRATION_GITHUB_CLIENT_SECRET=
# Set only for GitHub Enterprise Server, e.g https://github.example.com
INTEGRATION_GITHUB_BASE_URL=
INTEGRATION_GITLAB_CLIENT_ID=
INTEGRATION_GITLAB_CLIENT_SECRET=
# Set only for self-hosted GitLab, e.g https://gitlab.example.com
INTEGRATION_GITLAB_BASE_URL=
DISABLE_TELEMETRY=false
# Allow other sites to embed Docmost in an iframe.
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:26-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
RUN npm install -g pnpm@11.25.0
RUN npm install -g pnpm@11.23.0
FROM base AS builder
-1
View File
@@ -21,7 +21,6 @@
"@docmost/base-formula": "workspace:*",
"@docmost/editor-ext": "workspace:*",
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@fontsource-variable/inter": "5.3.0",
"@mantine/core": "9.3.2",
"@mantine/dates": "9.3.2",
"@mantine/form": "9.3.2",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.",
"MCP Server URL": "MCP-Server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
"Enforce OAuth": "OAuth erzwingen",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-Assistenten müssen sich über OAuth mit einem Docmost-Konto verbinden. API-Schlüssel können nicht mit dem MCP-Server verwendet werden.",
"Toggle enforce OAuth for MCP": "OAuth-Erzwingung für MCP umschalten",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Unterstützte Tools",
"MCP server URL:": "MCP-Server-URL:",
"Learn more": "Mehr erfahren",
+21 -140
View File
@@ -14,6 +14,7 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
"Assigned to {{name}}": "Assigned to {{name}}",
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
"Can create and edit pages in space.": "Can create and edit pages in space.",
"Can edit": "Can edit",
@@ -22,6 +23,7 @@
"Can view": "Can view",
"Can view pages in space but not edit.": "Can view pages in space but not edit.",
"Cancel": "Cancel",
"Card": "Card",
"Change email": "Change email",
"Change password": "Change password",
"Change photo": "Change photo",
@@ -30,6 +32,8 @@
"Choose your preferred interface language.": "Choose your preferred interface language.",
"Choose your preferred page width.": "Choose your preferred page width.",
"Confirm": "Confirm",
"Connect to {{name}} to preview": "Connect to {{name}} to preview",
"Connected as {{label}}": "Connected as {{label}}",
"Copy as Markdown": "Copy as Markdown",
"Copy link": "Copy link",
"Create": "Create",
@@ -41,6 +45,16 @@
"Dark": "Dark",
"Date": "Date",
"Delete": "Delete",
"Initiative": "Initiative",
"Last modified by {{name}}": "Last modified by {{name}}",
"Open in Slack": "Open in Slack",
"Paid": "Paid",
"Paste as": "Paste as",
"Project": "Project",
"Uninstall {{name}}": "Uninstall {{name}}",
"Disconnect {{name}}": "Disconnect {{name}}",
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.": "This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.": "This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
"Remove from page": "Remove from page",
"Base options": "Base options",
"Delete group": "Delete group",
@@ -150,6 +164,8 @@
"page": "page",
"Page deleted successfully": "Page deleted successfully",
"Page history": "Page history",
"replies": "replies",
"reply": "reply",
"Select version": "Select version",
"Highlight changes": "Highlight changes",
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
@@ -184,6 +200,8 @@
"Invitation sent": "Invitation sent",
"Settings": "Settings",
"Setup workspace": "Setup workspace",
"show less": "show less",
"show more": "show more",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Slug": "Slug",
@@ -206,7 +224,9 @@
"Theme": "Theme",
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
"Toggle full page width": "Toggle full page width",
"Toggle {{name}} integration": "Toggle {{name}} integration",
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
"Unassigned": "Unassigned",
"untitled": "untitled",
"Untitled": "Untitled",
"Updated successfully": "Updated successfully",
@@ -793,10 +813,6 @@
"Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission",
"Removed page permission": "Removed page permission",
"Changed page permission": "Changed page permission",
"Requested password reset": "Requested password reset",
"Created template": "Created template",
"Deleted template": "Deleted template",
"day": "day",
"days": "days",
"week": "week",
@@ -884,9 +900,6 @@
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
"Page verification expires soon": "Page verification expires soon",
"Page verification has expired": "Page verification has expired",
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM destination <bold>{{name}}</bold> is failing",
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures",
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM destination <bold>{{name}}</bold> recovered",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
@@ -1344,137 +1357,5 @@
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
"Title only": "Title only",
"you": "you",
"Allow public spaces": "Allow public spaces",
"Space admins can publish their spaces to the web.": "Space admins can publish their spaces to the web.",
"Toggle allow public spaces": "Toggle allow public spaces",
"Publish space to the web": "Publish space to the web",
"Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?",
"Make this space publicly readable by anyone on the internet.": "Make this space publicly readable by anyone on the internet.",
"Toggle publish space to the web": "Toggle publish space to the web",
"Allow search engines to index": "Allow search engines to index",
"Let public pages in this space appear in search engine results.": "Let public pages in this space appear in search engine results.",
"Toggle search engine indexing": "Toggle search engine indexing",
"Public space link": "Public space link",
"Copy public space link": "Copy public space link",
"Renaming the space slug will break public links.": "Renaming the space slug will break public links.",
"Failed to update space": "Failed to update space",
"This space is public": "This space is public",
"Anyone on the internet can read the pages in this space, except restricted pages.": "Anyone on the internet can read the pages in this space, except restricted pages.",
"Open public site": "Open public site",
"Public": "Public",
"This space has no public pages yet.": "This space has no public pages yet.",
"On this page": "On this page",
"Previous": "Previous",
"Next": "Next",
"Show hidden pages": "Show hidden pages",
"Page navigation": "Page navigation",
"Toggle sidebar": "Toggle sidebar",
"Toggle table of contents": "Toggle table of contents",
"Appearance": "Appearance",
"Forest": "Forest",
"Violet": "Violet",
"Light mode color": "Light mode color",
"Dark mode color": "Dark mode color",
"Choose the primary color of the public docs site.": "Choose the primary color of the public docs site.",
"Show page author": "Show page author",
"Display the page creator's name on public pages.": "Display the page creator's name on public pages.",
"Toggle show page author": "Toggle show page author",
"Show last updated": "Show last updated",
"Display when each page was last updated.": "Display when each page was last updated.",
"Toggle show last updated": "Toggle show last updated",
"Open public page": "Open public page",
"Toggle color scheme": "Toggle color scheme",
"Ember": "Ember",
"Rose": "Rose",
"Documentation": "Documentation",
"All published spaces.": "All published spaces.",
"No public spaces yet.": "No public spaces yet.",
"Show public directory": "Show public directory",
"List published spaces at /docs for anyone to browse.": "List published spaces at /docs for anyone to browse.",
"Toggle show public directory": "Toggle show public directory",
"Show in public directory": "Show in public directory",
"List this space in the public directory at /docs.": "List this space in the public directory at /docs.",
"Toggle show in public directory": "Toggle show in public directory",
"Open public space link": "Open public space link",
"Disable public spaces": "Disable public spaces",
"Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?",
"This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?",
"Allow": "Allow",
"Shared pages": "Shared pages",
"Published spaces": "Published spaces",
"Spaces published to the web will appear here": "Spaces published to the web will appear here",
"No published spaces": "No published spaces",
"Published by": "Published by",
"Published at": "Published at",
"Open space": "Open space",
"Unpublish": "Unpublish",
"Unpublish space": "Unpublish space",
"This space will no longer be publicly accessible. Are you sure?": "This space will no longer be publicly accessible. Are you sure?",
"More options for {{name}}": "More options for {{name}}",
"Edit page": "Edit page",
"Sign in": "Sign in",
"Open app": "Open app",
"No spaces match your search.": "No spaces match your search.",
"Welcome to our documentation": "Welcome to our documentation",
"Guides, references and answers across all our published spaces.": "Guides, references and answers across all our published spaces.",
"Guides, references and answers across all our spaces.": "Guides, references and answers across all our spaces.",
"Search documentation...": "Search documentation...",
"1 published space": "1 published space",
"{{count}} published spaces": "{{count}} published spaces",
"Actions": "Actions",
"Add destination": "Add destination",
"Are you sure you want to delete the destination": "Are you sure you want to delete the destination",
"Audit logs": "Audit logs",
"Audit logs & SIEM": "Audit logs & SIEM",
"Auth header name": "Auth header name",
"Auth header prefix": "Auth header prefix",
"Body format": "Body format",
"Created SIEM destination": "Created SIEM destination",
"Datadog site": "Datadog site",
"Defaults to this instance's hostname": "Defaults to this instance's hostname",
"Delete destination": "Delete destination",
"Deleted SIEM destination": "Deleted SIEM destination",
"Destination created": "Destination created",
"Destination deleted": "Destination deleted",
"Destination updated": "Destination updated",
"Disabled": "Disabled",
"Edit destination": "Edit destination",
"Endpoint URL": "Endpoint URL",
"Failing": "Failing",
"Failing since {{time}}": "Failing since {{time}}",
"HEC token": "HEC token",
"HEC URL": "HEC URL",
"Healthy": "Healthy",
"Hide advanced options": "Hide advanced options",
"Host": "Host",
"Index": "Index",
"Insecure: connections can be intercepted.": "Insecure: connections can be intercepted.",
"JSON array": "JSON array",
"Last delivered": "Last delivered",
"Last error": "Last error",
"Leave empty to use the token's default index": "Leave empty to use the token's default index",
"Maximum of {{limit}} destinations reached": "Maximum of {{limit}} destinations reached",
"Could not load SIEM destinations: {{message}}": "Could not load SIEM destinations: {{message}}",
"No destinations yet": "No destinations yet",
"Preset": "Preset",
"Retry now": "Retry now",
"Retry scheduled": "Retry scheduled",
"Send test event": "Send test event",
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Sent in the auth header below. Leave empty if your receiver does not need one.",
"Service": "Service",
"Show advanced options": "Show advanced options",
"SIEM": "SIEM",
"SIEM streaming": "SIEM streaming",
"SIEM streaming requires an Enterprise license.": "SIEM streaming requires an Enterprise license.",
"Source": "Source",
"Sourcetype": "Sourcetype",
"Tags": "Tags",
"Test connection": "Test connection",
"Test event delivered successfully.": "Test event delivered successfully.",
"Test the connection before saving.": "Test the connection before saving.",
"Test event delivered to {{name}}": "Test event delivered to {{name}}",
"Updated SIEM destination": "Updated SIEM destination",
"Verify TLS certificate": "Verify TLS certificate",
"e.g. Splunk prod": "e.g. Splunk prod"
"you": "you"
}
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.",
"MCP Server URL": "URL del servidor MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.",
"Enforce OAuth": "Exigir OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Los asistentes de IA deben conectarse con una cuenta de Docmost mediante OAuth. No se pueden usar claves API con el servidor MCP.",
"Toggle enforce OAuth for MCP": "Activar o desactivar la exigencia de OAuth para MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Herramientas compatibles",
"MCP server URL:": "URL del servidor MCP:",
"Learn more": "Más información",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.",
"MCP Server URL": "URL du serveur MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.",
"Enforce OAuth": "Imposer OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Les assistants IA doivent se connecter avec un compte Docmost via OAuth. Les clés API ne peuvent pas être utilisées avec le serveur MCP.",
"Toggle enforce OAuth for MCP": "Activer ou désactiver limposition dOAuth pour MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Outils pris en charge",
"MCP server URL:": "URL du serveur MCP :",
"Learn more": "En savoir plus",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.",
"MCP Server URL": "URL del server MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite OAuth.",
"Enforce OAuth": "Rendi obbligatorio OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Gli assistenti AI devono connettersi con un account Docmost tramite OAuth. Le chiavi API non possono essere utilizzate con il server MCP.",
"Toggle enforce OAuth for MCP": "Attiva/disattiva l'obbligo di OAuth per MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Strumenti supportati",
"MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"MCP Server URL": "MCP サーバーの URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
"Enforce OAuth": "OAuth を必須化",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI アシスタントは OAuth を使用して Docmost アカウントに接続する必要があります。MCP サーバーでは API キーは使用できません。",
"Toggle enforce OAuth for MCP": "MCP の OAuth 必須化を切り替え",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "サポートされているツール",
"MCP server URL:": "MCP サーバーの URL:",
"Learn more": "詳細を見る",
@@ -1191,7 +1191,7 @@
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
"Delete {{count}} rows?_one": "1行を削除しますか?",
"Delete {{count}} rows?_one": "Delete 1 row?",
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
"Descending": "降順",
"Discard": "破棄",
@@ -1291,9 +1291,9 @@
"Value": "値",
"View updated for everyone": "ビューが全員向けに更新されました",
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
"{{count}} rows deleted_one": "1行を削除しました",
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1件を選択中",
"{{count}} selected_one": "1 selected",
"{{count}} selected_other": "{{count}} selected",
"Compare": "比較",
"Compare versions": "バージョンを比較",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.",
"MCP Server URL": "MCP 서버 URL",
"Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
"Enforce OAuth": "OAuth 강제",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 도우미는 OAuth를 통해 Docmost 계정에 연결해야 합니다. MCP 서버에서는 API 키를 사용할 수 없습니다.",
"Toggle enforce OAuth for MCP": "MCP에 대해 OAuth 강제 전환",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "지원되는 도구",
"MCP server URL:": "MCP 서버 URL:",
"Learn more": "자세히 알아보기",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.",
"MCP Server URL": "MCP-server-URL",
"Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.",
"Enforce OAuth": "OAuth afdwingen",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-assistenten moeten verbinding maken met een Docmost-account via OAuth. API-sleutels kunnen niet worden gebruikt met de MCP-server.",
"Toggle enforce OAuth for MCP": "OAuth afdwingen voor MCP in- of uitschakelen",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Ondersteunde tools",
"MCP server URL:": "MCP-server-URL:",
"Learn more": "Meer informatie",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.",
"MCP Server URL": "URL do servidor MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via OAuth.",
"Enforce OAuth": "Exigir OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "Os assistentes de IA devem se conectar com uma conta do Docmost via OAuth. Chaves de API não podem ser usadas com o servidor MCP.",
"Toggle enforce OAuth for MCP": "Ativar/desativar exigência de OAuth para MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Ferramentas compatíveis",
"MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
"Enforce OAuth": "Сделать OAuth обязательным",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-помощники должны подключаться к учетной записи Docmost через OAuth. Ключи API нельзя использовать с MCP-сервером.",
"Toggle enforce OAuth for MCP": "Переключить обязательное использование OAuth для MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Поддерживаемые инструменты",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Подробнее",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
"Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
"Enforce OAuth": "Зробити OAuth обов’язковим",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI-асистенти повинні підключатися до облікового запису Docmost через OAuth. Ключі API не можна використовувати з MCP-сервером.",
"Toggle enforce OAuth for MCP": "Увімкнути обов’язковий OAuth для MCP",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "Підтримувані інструменти",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Дізнатися більше",
@@ -708,9 +708,9 @@
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"MCP Server URL": "MCP 服务器 URL",
"Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
"Enforce OAuth": "强制使用 OAuth",
"Enforce OAuth": "Enforce OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI 助手必须通过 OAuth 使用 Docmost 账户连接。MCP 服务器不能使用 API 密钥。",
"Toggle enforce OAuth for MCP": "切换 MCP 的强制使用 OAuth 设置",
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
"Supported tools": "支持的工具",
"MCP server URL:": "MCP 服务器 URL",
"Learn more": "了解更多",
+12 -23
View File
@@ -44,15 +44,6 @@ const ShareLayout = lazy(
() => import("@/features/share/components/share-layout.tsx"),
);
const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx"));
const PublicSpacePage = lazy(
() => import("@/pages/public-space/public-space-page.tsx"),
);
const PublicSpaceLayout = lazy(
() => import("@/features/public-space/components/public-space-layout.tsx"),
);
const PublicSpaceDirectoryPage = lazy(
() => import("@/pages/public-space/public-space-directory-page.tsx"),
);
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
const MfaChallengePage = lazy(() =>
import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({
@@ -84,6 +75,15 @@ const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page"));
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
const Integrations = lazy(
() => import("@/features/integration/pages/integrations.tsx"),
);
const Connections = lazy(
() => import("@/features/integration/pages/connections.tsx"),
);
const SlackLinkPage = lazy(
() => import("@/features/integration/pages/slack-link.tsx"),
);
export default function App() {
const { t } = useTranslation();
@@ -128,18 +128,10 @@ export default function App() {
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/docs"} element={<PublicSpaceDirectoryPage />} />
<Route element={<PublicSpaceLayout />}>
<Route path={"/docs/:spaceSlug"} element={<PublicSpacePage />} />
<Route
path={"/docs/:spaceSlug/:pageSlug"}
element={<PublicSpacePage />}
/>
</Route>
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route path={"/integrations/slack/link"} element={<SlackLinkPage />} />
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
@@ -173,6 +165,7 @@ export default function App() {
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"account/connections"} element={<Connections />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -184,12 +177,8 @@ export default function App() {
<Route path={"ai"} element={<AiSettings />} />
<Route path={"ai/mcp"} element={<AiSettings />} />
<Route path={"audit"} element={<AuditLogs />} />
<Route path={"audit/siem"} element={<AuditLogs />} />
<Route
path={"siem"}
element={<Navigate to="/settings/audit/siem" replace />}
/>
<Route path={"verifications"} element={<VerifiedPages />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
@@ -0,0 +1,22 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GithubIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M256 6.3C114.6 6.3 0 120.9 0 262.3c0 113.3 73.3 209 175 242.9 12.8 2.2 17.6-5.4 17.6-12.2 0-6.1-.3-26.2-.3-47.7-64.3 11.8-81-15.7-86.1-30.1-2.9-7.4-15.4-30.1-26.2-36.2-9-4.8-21.8-16.6-.3-17 20.2-.3 34.6 18.6 39.4 26.2 23 38.7 59.8 27.8 74.6 21.1 2.2-16.6 9-27.8 16.3-34.2-57-6.4-116.5-28.5-116.5-126.4 0-27.8 9.9-50.9 26.2-68.8-2.6-6.4-11.5-32.6 2.6-67.8 0 0 21.4-6.7 70.4 26.2 20.5-5.8 42.2-8.6 64-8.6s43.5 2.9 64 8.6c49-33.3 70.4-26.2 70.4-26.2 14.1 35.2 5.1 61.4 2.6 67.8 16.3 17.9 26.2 40.6 26.2 68.8 0 98.2-59.8 120-116.8 126.4 9.3 8 17.3 23.4 17.3 47.4 0 34.2-.3 61.8-.3 70.4 0 6.7 4.8 14.7 17.6 12.2C438.7 471.3 512 375.3 512 262.3c0-141.4-114.6-256-256-256"
/>
</svg>
);
}
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GitlabIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="93.97 97.52 192.05 184.99"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#e24329"
d="m282.83 170.73-.27-.69-26.14-68.22a6.8 6.8 0 0 0-2.69-3.24 7 7 0 0 0-8 .43 7 7 0 0 0-2.32 3.52l-17.65 54h-71.47l-17.65-54a6.86 6.86 0 0 0-2.32-3.53 7 7 0 0 0-8-.43 6.87 6.87 0 0 0-2.69 3.24L97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91 40.06-30 .1-.08a48.56 48.56 0 0 0 16.08-56.04"
/>
<path
fill="#fc6d26"
d="m282.83 170.73-.27-.69a88.3 88.3 0 0 0-35.15 15.8L190 229.25c19.55 14.79 36.57 27.64 36.57 27.64l40.06-30 .1-.08a48.56 48.56 0 0 0 16.1-56.08"
/>
<path
fill="#fca326"
d="m153.43 256.89 19.7 14.91 12 9.06a8.07 8.07 0 0 0 9.76 0l12-9.06 19.7-14.91S209.55 244 190 229.25c-19.55 14.75-36.57 27.64-36.57 27.64"
/>
<path
fill="#fc6d26"
d="M132.58 185.84A88.2 88.2 0 0 0 97.44 170l-.26.69a48.54 48.54 0 0 0 16.1 56.1l.09.07.24.17 39.82 29.82L190 229.21Z"
/>
</svg>
);
}
@@ -0,0 +1,28 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function GoogleDocsIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 47 65"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M29.375 0H4.406C1.983 0 0 1.994 0 4.432v56.136C0 63.006 1.983 65 4.406 65h38.188C45.017 65 47 63.006 47 60.568v-42.84L36.719 10.34z"
/>
<path
fill="#A1C2FA"
d="M29.375 0v13.295c0 2.449 1.972 4.432 4.406 4.432H47z"
/>
<path
fill="#F1F1F1"
d="M11.75 47.273h23.5v-2.955h-23.5zm0 5.909h17.625v-2.955H11.75zm0-20.682v2.955h23.5V32.5zm0 8.864h23.5v-2.955h-23.5z"
/>
</svg>
);
}
+8 -2
View File
@@ -1,10 +1,16 @@
export { AirtableIcon } from "./airtable-icon.tsx";
export { FigmaIcon } from "./figma-icon.tsx";
export { GithubIcon } from "./github-icon.tsx";
export { GitlabIcon } from "./gitlab-icon.tsx";
export { GoogleDocsIcon } from "./google-docs-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { JiraIcon } from "./jira-icon.tsx";
export { LinearIcon } from "./linear-icon.tsx";
export { TypeformIcon } from "./typeform-icon.tsx";
export { VimeoIcon } from "./vimeo-icon.tsx";
export { MiroIcon } from "./miro-icon.tsx";
export { GoogleDriveIcon } from "./google-drive-icon.tsx";
export { GoogleSheetsIcon } from "./google-sheets-icon.tsx";
export { SlackIcon } from "./slack-icon.tsx";
export { FramerIcon } from "./framer-icon.tsx";
export { LoomIcon } from "./loom-icon.tsx";
export { YoutubeIcon } from "./youtube-icon.tsx";
@@ -0,0 +1,38 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function JiraIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<defs>
<linearGradient id="jira-a" x1="380.896" x2="279.145" y1="390.609" y2="282.574" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
<linearGradient id="jira-b" x1="265.965" x2="148.117" y1="270.661" y2="152.607" gradientTransform="matrix(1 0 0 -1 0 514)" gradientUnits="userSpaceOnUse">
<stop offset=".176" stopColor="#0052cc" />
<stop offset="1" stopColor="#2684ff" />
</linearGradient>
</defs>
<path
fill="#2684ff"
d="M490.6 7.4H243.9c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V28.1c.1-11.7-9.2-20.7-21.3-20.7"
/>
<path
fill="url(#jira-a)"
d="M368.7 126.5H121.9c0 59.6 49.9 108 111.2 108h45.6v42.9c0 59.6 49.9 108 111.2 108V147.3c.2-11.1-9.1-20.8-21.2-20.8"
/>
<path
fill="url(#jira-b)"
d="M246.7 246.3H0c0 59.6 49.9 108 111.2 108h45.6v42.2c0 59.6 49.9 108 111.2 108V267.1c.1-11.8-9.9-20.8-21.3-20.8"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function LinearIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 100 100"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="currentColor"
d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z"
/>
</svg>
);
}
+11 -2
View File
@@ -8,11 +8,20 @@ export function MiroIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
viewBox="3 2 395 395"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M17.392 0H13.9L17 4.808 10.444 0H6.949l3.102 6.3L3.494 0H0l3.05 8.131L0 24h3.494L10.05 6.985 6.949 24h3.494L17 5.494 13.899 24h3.493L24 3.672 17.392 0z" />
fill="#FFDD33"
d="M3 100.754C3 46.2604 47.2435 2 101.754 2H299.246C353.756 2 398 46.2435 398 100.754V298.246C398 352.756 353.756 397 299.246 397H101.754C47.2435 397 3 352.756 3 298.246V100.754Z"
/>
<path
fill="#1C1C1E"
fillRule="evenodd"
clipRule="evenodd"
d="M265.573 77.3491H229.74L259.629 129.85L193.906 77.3491H158.072L190.934 141.468L122.238 77.3491H86.4041L122.238 159.031L86.4041 322.377H122.238L190.934 147.396L158.072 322.377H193.906L259.629 135.693L229.74 322.377H265.573L331.297 118.232L265.573 77.4335V77.3491Z"
/>
</svg>
);
}
@@ -0,0 +1,32 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
export function SlackIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
style={{ width: rem(size), height: rem(size) }}
>
<path
d="M107.9 323.6c0 29.7-24 53.8-53.8 53.8S.3 353.4.3 323.6c0-29.7 24-53.8 53.8-53.8h53.8zm26.9 0c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8V458c0 29.7-24 53.8-53.8 53.8s-53.8-24-53.8-53.8z"
fill="#e01e5a"
/>
<path
d="M188.6 107.7c-29.7 0-53.8-24-53.8-53.8S158.8.1 188.6.1s53.8 24 53.8 53.8v53.8zm0 27.3c29.7 0 53.8 24 53.8 53.8s-24 53.8-53.8 53.8H53.8C24 242.6 0 218.5 0 188.8S24 135 53.8 135z"
fill="#36c5f0"
/>
<path
d="M404.1 188.8c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8-24 53.8-53.8 53.8h-53.8zm-26.9 0c0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8V54c0-29.7 24-53.8 53.8-53.8s53.8 24 53.8 53.8z"
fill="#2eb67d"
/>
<path
d="M323.4 404.3c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8-29.7 0-53.8-24-53.8-53.8v-53.8zm0-26.9c-29.7 0-53.8-24-53.8-53.8s24-53.8 53.8-53.8h134.8c29.7 0 53.8 24 53.8 53.8 0 29.7-24 53.8-53.8 53.8z"
fill="#ecb22e"
/>
</svg>
);
}
@@ -13,6 +13,7 @@ import {
IconKey,
IconWorld,
IconSparkles,
IconPlug,
IconHistory,
IconShieldCheck,
} from "@tabler/icons-react";
@@ -74,6 +75,11 @@ const groupedData: DataGroup[] = [
path: "/settings/account/api-keys",
feature: Feature.API_KEYS,
},
{
label: "Connections",
icon: IconPlug,
path: "/settings/account/connections",
},
],
},
{
@@ -118,13 +124,19 @@ const groupedData: DataGroup[] = [
role: "admin",
},
{
label: "Audit logs & SIEM",
label: "Audit log",
icon: IconHistory,
path: "/settings/audit",
feature: Feature.AUDIT_LOGS,
role: "owner",
env: "selfhosted",
},
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
role: "admin",
},
],
},
{
@@ -219,7 +231,7 @@ export default function SettingsSidebar() {
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
case "Audit logs & SIEM":
case "Audit log":
prefetchHandler = prefetchAuditLogs;
break;
case "Verified pages":
@@ -22,7 +22,6 @@ export const auditEventLabels: Record<string, string> = {
"user.role_changed": "Changed user role",
"user.password_changed": "Changed password",
"user.password_reset": "Reset password",
"user.password_reset_requested": "Requested password reset",
"user.updated": "Updated user",
"user.deactivated": "Deactivated user",
"user.activated": "Activated user",
@@ -63,7 +62,6 @@ export const auditEventLabels: Record<string, string> = {
"page.restriction_removed": "Removed page restriction",
"page.permission_added": "Added page permission",
"page.permission_removed": "Removed page permission",
"page.permission_role_changed": "Changed page permission",
"page.verification_created": "Created page verification",
"page.verification_updated": "Updated page verification",
"page.verification_removed": "Removed page verification",
@@ -81,13 +79,6 @@ export const auditEventLabels: Record<string, string> = {
"license.activated": "Activated license",
"license.removed": "Removed license",
"siem_destination.created": "Created SIEM destination",
"siem_destination.updated": "Updated SIEM destination",
"siem_destination.deleted": "Deleted SIEM destination",
"template.created": "Created template",
"template.deleted": "Deleted template",
};
export function getEventLabel(event: string): string {
@@ -114,10 +105,6 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "user.activated", label: "Activated user" },
{ value: "user.role_changed", label: "Changed user role" },
{ value: "user.password_changed", label: "Changed password" },
{
value: "user.password_reset_requested",
label: "Requested password reset",
},
{ value: "user.mfa_enabled", label: "Enabled MFA" },
{ value: "user.mfa_disabled", label: "Disabled MFA" },
],
@@ -160,10 +147,6 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "page.restriction_removed", label: "Removed page restriction" },
{ value: "page.permission_added", label: "Added page permission" },
{ value: "page.permission_removed", label: "Removed page permission" },
{
value: "page.permission_role_changed",
label: "Changed page permission",
},
{ value: "page.verification_created", label: "Created page verification" },
{ value: "page.verification_updated", label: "Updated page verification" },
{ value: "page.verification_removed", label: "Removed page verification" },
@@ -210,19 +193,4 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "license.removed", label: "Removed license" },
],
},
{
group: "SIEM",
items: [
{ value: "siem_destination.created", label: "Created SIEM destination" },
{ value: "siem_destination.updated", label: "Updated SIEM destination" },
{ value: "siem_destination.deleted", label: "Deleted SIEM destination" },
],
},
{
group: "Template",
items: [
{ value: "template.created", label: "Created template" },
{ value: "template.deleted", label: "Deleted template" },
],
},
];
+111 -143
View File
@@ -7,12 +7,10 @@ import {
Popover,
Select,
Space,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { IconSettings } from "@tabler/icons-react";
import SettingsTitle from "@/components/settings/settings-title";
import Paginate from "@/components/common/paginate";
@@ -25,7 +23,6 @@ import {
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel";
import useUserRole from "@/hooks/use-user-role";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
@@ -51,8 +48,6 @@ export default function AuditLogs() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
const location = useLocation();
const navigate = useNavigate();
const [eventFilter, setEventFilter] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -90,8 +85,6 @@ export default function AuditLogs() {
const { data, isLoading } = useAuditLogsQuery(params);
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
if (!isOwner) {
return null;
}
@@ -101,150 +94,125 @@ export default function AuditLogs() {
resetCursor();
};
const handleTabChange = (value: string | null) => {
if (value === "siem") {
navigate("/settings/audit/siem");
} else {
navigate("/settings/audit");
}
};
return (
<>
<DocumentTitle title={t("Audit logs & SIEM")} />
<DocumentTitle title={t("Audit log")} />
<SettingsTitle title={t("Audit logs & SIEM")} />
<SettingsTitle title={t("Audit log")} />
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Tabs.List>
<Tabs.Tab fw={500} value="audit">
{t("Audit logs")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="siem">
{t("SIEM")}
</Tabs.Tab>
</Tabs.List>
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Tabs.Panel value="audit" pt="md">
<Group mb="md" gap="sm">
<Select
placeholder={t("Filter by event")}
data={eventFilterOptions.map((group) => ({
group: t(group.group),
items: group.items.map((item) => ({
value: item.value,
label: t(item.label),
})),
}))}
value={eventFilter}
onChange={handleEventChange}
clearable
searchable
w={220}
size="sm"
/>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<Popover
position="bottom-end"
shadow="md"
width={260}
withArrow
opened={settingsOpen}
onChange={(opened) => {
if (!opened) resetRetentionForm();
setSettingsOpen(opened);
}}
>
<Popover.Target>
<Tooltip label={t("Audit settings")}>
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
<IconSettings size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Text fz="sm" fw={500} mb={4}>
{t("Retention")}
</Text>
<Text fz="xs" c="dimmed" mb="sm">
{t("Logs older than this period are automatically deleted.")}
</Text>
<Group gap="xs" wrap="nowrap" mb="sm">
<NumberInput
value={retentionAmount}
onChange={(val) => setRetentionAmount(val)}
min={1}
hideControls
size="sm"
w={60}
/>
<Select
data={[
{ value: "days", label: t("days") },
{ value: "months", label: t("months") },
{ value: "years", label: t("years") },
]}
value={retentionUnit}
onChange={(value) => {
if (value === "days" || value === "months" || value === "years") {
setRetentionUnit(value);
}
}}
size="sm"
style={{ flex: 1 }}
comboboxProps={{ withinPortal: false }}
/>
</Group>
<Group gap="xs" grow>
<Button
size="xs"
variant="default"
onClick={() => {
resetRetentionForm();
setSettingsOpen(false);
}}
>
{t("Cancel")}
</Button>
<Button
size="xs"
onClick={() => {
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
const clamped = Math.max(1, num);
setRetentionAmount(clamped);
const days = retentionToDays(clamped, retentionUnit);
if (days !== currentDays) {
updateRetention.mutate({ auditRetentionDays: days });
}
setSettingsOpen(false);
}}
loading={updateRetention.isPending}
>
{t("Save")}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<AuditLogsTable items={data?.items} isLoading={isLoading} />
<Space h="md" />
<Space h="md" />
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="siem" pt="md">
<SiemStreamingPanel />
</Tabs.Panel>
</Tabs>
{data?.items && data.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</>
);
}
@@ -18,7 +18,6 @@ export type FieldProps = {
rowId: string;
readOnly: boolean;
onChange: (value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
};
type FieldShellProps = {
@@ -100,10 +99,9 @@ type DetailFieldProps = {
row: IBaseRow;
readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange: (editing: boolean) => void;
};
export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row)
@@ -114,7 +112,6 @@ export function DetailField({ property, row, readOnly, onUpdate, onEditingChange
rowId: row.id,
readOnly,
onChange: (next: unknown) => onUpdate(property.id, next),
onEditingChange
};
switch (property.type) {
@@ -9,13 +9,7 @@ const normalize = (s: string) => {
return trimmed.length ? trimmed : null;
};
export function FieldLongText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -29,7 +23,6 @@ export function FieldLongText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -57,10 +50,7 @@ export function FieldLongText({
className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }}
value={draft}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -11,13 +11,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : "";
export function FieldNumber({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value));
@@ -42,7 +36,6 @@ export function FieldNumber({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(toDraft(value));
@@ -61,7 +54,6 @@ export function FieldNumber({
onFocus={() => {
setDraft(toDraft(value));
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => {
const v = e.target.value;
@@ -5,13 +5,7 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const text = toText(value);
const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false);
@@ -26,7 +20,6 @@ export function FieldText({
const commit = () => {
setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setDraft(text);
@@ -61,10 +54,7 @@ export function FieldText({
className={classes.fieldInput}
value={draft}
maxLength={1000}
onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onFocus={() => setFocused(true)}
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => {
@@ -17,7 +17,6 @@ type PropertyRowProps = {
onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
autoFocusValue?: boolean;
onAutoFocused?: () => void;
};
@@ -30,7 +29,6 @@ export function PropertyRow({
onMenuOpenChange,
onMenuDirtyChange,
onUpdate,
onEditingChange,
autoFocusValue,
onAutoFocused,
}: PropertyRowProps) {
@@ -114,7 +112,6 @@ export function PropertyRow({
row={row}
readOnly={!canEdit}
onUpdate={onUpdate}
onEditingChange={onEditingChange}
/>
</div>
);
@@ -75,7 +75,6 @@ export function RowDetailModal({
const isSaving = updateRowMutation.isPending;
const opened = !!openRowId;
const [editingField, setEditingField] = useState(false);
// One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to
@@ -91,7 +90,6 @@ export function RowDetailModal({
useEffect(() => {
setOpenMenuId(null);
menuDirtyRef.current = false;
setEditingField(false);
}, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => {
@@ -295,7 +293,7 @@ export function RowDetailModal({
row={row}
primaryProperty={primaryProperty}
canEdit={canEdit}
onEditingChange={setEditingField}
onClose={onClose}
onCommit={(value) => {
if (!primaryProperty) return;
updateRowMutation.mutate({
@@ -319,7 +317,6 @@ export function RowDetailModal({
autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id}
onEditingChange={setEditingField}
onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened)
}
@@ -370,38 +367,16 @@ export function RowDetailModal({
) : null}
</div>
<div className={classes.kbdHint}>
{editingField ? (
{rowIndex >= 0 && rows.length > 1 && (
<>
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Ctrl/Cmd</kbd>
<span className={classes.kbdPlus} >+</span>
<kbd className={classes.kbd}>Enter</kbd>
<span>{t("to save")}</span>
</span>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to reset")}</span>
</span>
</>
) : (
<>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</>
</>
)}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div>
</footer>
</>
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts";
@@ -9,7 +9,7 @@ type RowDetailTitleProps = {
primaryProperty: IBaseProperty | undefined;
canEdit: boolean;
onCommit: (value: string) => void;
onEditingChange?: (editing: boolean) => void;
onClose: () => void;
};
export function RowDetailTitle({
@@ -17,24 +17,13 @@ export function RowDetailTitle({
primaryProperty,
canEdit,
onCommit,
onEditingChange,
onClose,
}: RowDetailTitleProps) {
const { t } = useTranslation();
const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: "";
const [value, setValue] = useState(initial);
const cancelRef = useRef(false);
const commit = () => {
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setValue(initial);
return;
}
if (value !== initial) onCommit(value);
};
// Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => {
@@ -54,18 +43,18 @@ export function RowDetailTitle({
aria-label={primaryProperty?.name ?? t("Untitled")}
value={value}
maxLength={1000}
onFocus={() => {
onEditingChange?.(true);
}}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={commit}
onBlur={() => {
if (value !== initial) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if (e.key === "Enter") {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
(e.currentTarget as HTMLInputElement).blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
}
}}
/>
@@ -416,25 +416,9 @@
}
.kbdHint {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
width: 100%;
}
.kbdGroup {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
height: fit-content;
}
.kbdPlus {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: 11px;
}
.kbdSeparator {
+1 -2
View File
@@ -22,9 +22,8 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
INTEGRATIONS: 'integrations',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
PUBLIC_SPACE_APPEARANCE: 'public-space:appearance',
SIEM: 'siem',
} as const;
@@ -82,7 +82,6 @@ export default function SpacePublicSharingToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle space public sharing")}
/>
</Tooltip>
@@ -53,7 +53,6 @@ export default function SpaceViewerCommentsToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
size={"xs"}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
@@ -1,44 +0,0 @@
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
import { useDeleteSiemDestinationMutation } from "@/ee/siem/queries/siem-query";
interface DeleteDestinationModalProps {
opened: boolean;
onClose: () => void;
destination: ISiemDestination | null;
}
export function DeleteDestinationModal({ opened, onClose, destination }: DeleteDestinationModalProps) {
const { t } = useTranslation();
const deleteMutation = useDeleteSiemDestinationMutation();
const handleDelete = async () => {
if (!destination) return;
await deleteMutation.mutateAsync({ destinationId: destination.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Delete destination")}
size="md"
closeButtonProps={{ "aria-label": t("Close") }}
>
<Stack gap="md">
<Text>
{t("Are you sure you want to delete the destination")}{" "}
<strong>{destination?.name}</strong>?
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button color="red" onClick={handleDelete} loading={deleteMutation.isPending}>
{t("Delete")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -1,300 +0,0 @@
import { useEffect, useState } from "react";
import {
Alert,
Button,
Collapse,
Group,
Modal,
PasswordInput,
Select,
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { DATADOG_SITES, ISiemDestination, ISiemTestResult } from "@/ee/siem/types/siem.types";
import {
useCreateSiemDestinationMutation,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import {
DestinationFormValues,
initialValues,
toPayload,
validateForm,
} from "@/ee/siem/lib/destination-form";
import { DESTINATION_TYPE_LABELS } from "./destination-table";
interface DestinationFormModalProps {
opened: boolean;
onClose: () => void;
destination?: ISiemDestination | null;
}
function connectionKey(values: DestinationFormValues): string {
const { type, config, secrets } = toPayload(values);
return JSON.stringify({ type, config, secrets });
}
export function DestinationFormModal({ opened, onClose, destination }: DestinationFormModalProps) {
const { t } = useTranslation();
const isEdit = Boolean(destination);
const hasSecrets = destination?.hasSecrets ?? {};
const [advancedOpen, setAdvancedOpen] = useState(false);
const [testState, setTestState] = useState<{ result: ISiemTestResult | null; testedPayloadKey: string | null }>({
result: null,
testedPayloadKey: null,
});
const createMutation = useCreateSiemDestinationMutation();
const updateMutation = useUpdateSiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const form = useForm<DestinationFormValues>({
initialValues: initialValues(destination),
validate: (values) => validateForm(values, hasSecrets),
});
useEffect(() => {
if (opened) {
form.setValues(initialValues(destination));
form.resetDirty();
// eslint-disable-next-line react-hooks/set-state-in-effect
setTestState({ result: null, testedPayloadKey: null });
setAdvancedOpen(false);
}
}, [opened, destination?.id]);
const handleSubmit = async (values: DestinationFormValues) => {
const payload = toPayload(values);
try {
if (destination) {
await updateMutation.mutateAsync({
destinationId: destination.id,
name: payload.name,
config: payload.config,
secrets: payload.secrets,
enabled: payload.enabled,
});
} else {
await createMutation.mutateAsync(payload);
}
onClose();
} catch {}
};
const handleTest = async () => {
if (form.validate().hasErrors) return;
const payload = toPayload(form.values);
const testedPayloadKey = connectionKey(form.values);
setTestState((prev) => ({ ...prev, testedPayloadKey }));
try {
const result = await testMutation.mutateAsync({
type: payload.type,
config: payload.config,
secrets: payload.secrets,
destinationId: destination?.id,
});
setTestState({ result, testedPayloadKey });
} catch {
setTestState({ result: null, testedPayloadKey });
}
};
const type = form.values.type;
const showTls = type !== "datadog";
const currentPayloadKey = connectionKey(form.values);
const showTestResult = testState.result !== null && testState.testedPayloadKey === currentPayloadKey;
const connectionChanged = currentPayloadKey !== connectionKey(initialValues(destination));
const testPassed = showTestResult && testState.result.delivered;
const requiresTest = (!isEdit || connectionChanged) && !testPassed;
return (
<Modal
opened={opened}
onClose={onClose}
title={isEdit ? t("Edit destination") : t("Add destination")}
size="lg"
closeButtonProps={{ "aria-label": t("Close") }}
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("Preset")}
data={[
{ value: "splunk_hec", label: DESTINATION_TYPE_LABELS.splunk_hec },
{ value: "datadog", label: DESTINATION_TYPE_LABELS.datadog },
{ value: "http", label: DESTINATION_TYPE_LABELS.http },
]}
allowDeselect={false}
disabled={isEdit}
{...form.getInputProps("type")}
/>
<TextInput
label={t("Name")}
placeholder={t("e.g. Splunk prod")}
required
data-autofocus
{...form.getInputProps("name")}
/>
{type === "splunk_hec" && (
<>
<TextInput
label={t("HEC URL")}
placeholder="https://splunk.example.com:8088"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("HEC token")}
required={!hasSecrets.token}
{...form.getInputProps("token")}
/>
</>
)}
{type === "datadog" && (
<>
<Select
label={t("Datadog site")}
data={DATADOG_SITES.map((site) => ({ value: site, label: site }))}
allowDeselect={false}
{...form.getInputProps("site")}
/>
<PasswordInput
label={t("API key")}
required={!hasSecrets.apiKey}
{...form.getInputProps("apiKey")}
/>
</>
)}
{type === "http" && (
<>
<TextInput
label={t("Endpoint URL")}
placeholder="https://collector.example.com/docmost"
required
{...form.getInputProps("url")}
/>
<PasswordInput
label={t("Token")}
description={t("Sent in the auth header below. Leave empty if your receiver does not need one.")}
{...form.getInputProps("token")}
/>
</>
)}
<Button
variant="subtle"
size="compact-sm"
onClick={() => setAdvancedOpen((open) => !open)}
style={{ alignSelf: "flex-start" }}
>
{advancedOpen ? t("Hide advanced options") : t("Show advanced options")}
</Button>
<Collapse expanded={advancedOpen}>
<Stack gap="md">
{type === "splunk_hec" && (
<>
<TextInput
label={t("Index")}
description={t("Leave empty to use the token's default index")}
{...form.getInputProps("index")}
/>
<Group grow>
<TextInput label={t("Source")} {...form.getInputProps("source")} />
<TextInput label={t("Sourcetype")} {...form.getInputProps("sourcetype")} />
</Group>
<TextInput
label={t("Host")}
description={t("Defaults to this instance's hostname")}
{...form.getInputProps("host")}
/>
</>
)}
{type === "datadog" && (
<>
<TextInput label={t("Service")} {...form.getInputProps("service")} />
<TextInput label={t("Tags")} placeholder="env:prod,team:security" {...form.getInputProps("tags")} />
</>
)}
{type === "http" && (
<>
<Group grow>
<TextInput label={t("Auth header name")} {...form.getInputProps("authHeaderName")} />
<TextInput label={t("Auth header prefix")} {...form.getInputProps("authHeaderPrefix")} />
</Group>
<Select
label={t("Body format")}
data={[
{ value: "json", label: t("JSON array") },
{ value: "ndjson", label: "NDJSON" },
]}
allowDeselect={false}
{...form.getInputProps("format")}
/>
</>
)}
{showTls && (
<>
{!isCloud() && (
<Switch
label={t("Verify TLS certificate")}
description={
form.values.rejectUnauthorized
? undefined
: t("Insecure: connections can be intercepted.")
}
{...form.getInputProps("rejectUnauthorized", { type: "checkbox" })}
/>
)}
</>
)}
<Switch label={t("Enabled")} {...form.getInputProps("enabled", { type: "checkbox" })} />
</Stack>
</Collapse>
{showTestResult && (
<Alert
color={testState.result.delivered ? "green" : "red"}
icon={testState.result.delivered ? <IconCheck size={16} /> : <IconAlertCircle size={16} />}
>
{testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error}
</Alert>
)}
<Group justify="space-between" mt="md">
<Group gap="sm">
<Button variant="default" onClick={handleTest} loading={testMutation.isPending}>
{t("Test connection")}
</Button>
{requiresTest && (
<Text size="xs" c="dimmed">
{t("Test the connection before saving.")}
</Text>
)}
</Group>
<Group>
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
<Button type="submit" disabled={requiresTest} loading={createMutation.isPending || updateMutation.isPending}>
{isEdit ? t("Save") : t("Create")}
</Button>
</Group>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -1,15 +0,0 @@
import { Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ISiemDestination } from "@/ee/siem/types/siem.types";
export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) {
const { t } = useTranslation();
if (!destination.enabled) {
return <Badge color="gray" variant="light">{t("Disabled")}</Badge>;
}
if (destination.status === "failing") {
return <Badge color="red" variant="light">{t("Failing")}</Badge>;
}
return <Badge color="green" variant="light">{t("Healthy")}</Badge>;
}
@@ -1,155 +0,0 @@
import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core";
import {
IconDots,
IconEdit,
IconPlugConnected,
IconRefresh,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { formattedDate, timeAgo } from "@/lib/time.ts";
import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types";
import { DestinationStatusBadge } from "./destination-status-badge";
export const DESTINATION_TYPE_LABELS: Record<SiemDestinationType, string> = {
splunk_hec: "Splunk HEC",
datadog: "Datadog",
http: "Generic HTTP",
};
interface DestinationTableProps {
destinations?: ISiemDestination[];
isLoading?: boolean;
onEdit: (destination: ISiemDestination) => void;
onTest: (destination: ISiemDestination) => void;
onRetry: (destination: ISiemDestination) => void;
onDelete: (destination: ISiemDestination) => void;
onToggle: (destination: ISiemDestination, enabled: boolean) => void;
}
export function DestinationTable({
destinations,
isLoading,
onEdit,
onTest,
onRetry,
onDelete,
onToggle,
}: DestinationTableProps) {
const { t } = useTranslation();
return (
<Table.ScrollContainer minWidth={760}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Type")}</Table.Th>
<Table.Th>{t("Enabled")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Last delivered")}</Table.Th>
<Table.Th>{t("Last error")}</Table.Th>
<Table.Th aria-label={t("Actions")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{destinations && destinations.length > 0 ? (
destinations.map((destination) => (
<Table.Tr key={destination.id}>
<Table.Td>
<Text fz="sm" fw={500}>{destination.name}</Text>
</Table.Td>
<Table.Td>
<Text fz="sm">{DESTINATION_TYPE_LABELS[destination.type]}</Text>
</Table.Td>
<Table.Td>
<Switch
size="sm"
checked={destination.enabled}
onChange={(event) => onToggle(destination, event.currentTarget.checked)}
aria-label={t("Enabled")}
/>
</Table.Td>
<Table.Td>
<DestinationStatusBadge destination={destination} />
{destination.failingSince &&
(destination.status === "failing" ||
!destination.enabled) && (
<Text
fz="xs"
c="dimmed"
mt={4}
style={{ whiteSpace: "nowrap" }}
>
{t("Failing since {{time}}", {
time: formattedDate(
new Date(destination.failingSince),
),
})}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{destination.lastDeliveredAt
? timeAgo(new Date(destination.lastDeliveredAt))
: t("Never")}
</Text>
</Table.Td>
<Table.Td>
{destination.lastError ? (
<Tooltip label={destination.lastError} multiline w={320}>
<Text fz="xs" c="red" lineClamp={2} style={{ maxWidth: 260 }}>
{destination.lastError}
</Text>
</Tooltip>
) : (
<Text fz="xs" c="dimmed"></Text>
)}
</Table.Td>
<Table.Td>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("Actions")}>
<IconDots size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(destination)}>
{t("Edit")}
</Menu.Item>
<Menu.Item leftSection={<IconPlugConnected size={16} />} onClick={() => onTest(destination)}>
{t("Send test event")}
</Menu.Item>
<Menu.Item
leftSection={<IconRefresh size={16} />}
onClick={() => onRetry(destination)}
disabled={!destination.nextAttemptAt}
>
{t("Retry now")}
</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onDelete(destination)}>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
!isLoading && (
<Table.Tr>
<Table.Td colSpan={7}>
<Text fz="sm" c="dimmed" ta="center" py="md">
{t("No destinations yet")}
</Text>
</Table.Td>
</Table.Tr>
)
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,129 +0,0 @@
import { useState } from "react";
import { Alert, Button, Group, Tooltip } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import {
ISiemDestination,
SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
} from "@/ee/siem/types/siem.types";
import {
useRetrySiemDestinationMutation,
useSiemDestinationsQuery,
extractErrorMessage,
useTestSiemDestinationMutation,
useUpdateSiemDestinationMutation,
} from "@/ee/siem/queries/siem-query";
import { DestinationTable } from "@/ee/siem/components/destination-table";
import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal";
import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal";
export default function SiemStreamingPanel() {
const { t } = useTranslation();
const { isOwner } = useUserRole();
const hasFeature = useHasFeature(Feature.SIEM);
const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature);
const updateMutation = useUpdateSiemDestinationMutation();
const retryMutation = useRetrySiemDestinationMutation();
const testMutation = useTestSiemDestinationMutation();
const [formOpened, setFormOpened] = useState(false);
const [deleteOpened, setDeleteOpened] = useState(false);
const [selected, setSelected] = useState<ISiemDestination | null>(null);
if (!isOwner) {
return null;
}
const atDestinationLimit =
(data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE;
const handleTest = async (destination: ISiemDestination) => {
const result = await testMutation
.mutateAsync({
type: destination.type,
config: destination.config as unknown as Record<string, unknown>,
destinationId: destination.id,
})
.catch(() => null);
if (!result) return;
notifications.show({
message: result.delivered
? t("Test event delivered to {{name}}", { name: destination.name })
: result.error,
color: result.delivered ? "green" : "red",
});
};
return (
<>
{!hasFeature && (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" mb="md">
{t("SIEM streaming requires an Enterprise license.")}
</Alert>
)}
<Group justify="flex-end" mb="md">
<Tooltip
label={t("Maximum of {{limit}} destinations reached", {
limit: SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
})}
disabled={!atDestinationLimit}
>
<span>
<Button
onClick={() => {
setSelected(null);
setFormOpened(true);
}}
disabled={!hasFeature || atDestinationLimit}
>
{t("Add destination")}
</Button>
</span>
</Tooltip>
</Group>
{isError && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
{t("Could not load SIEM destinations: {{message}}", {
message: extractErrorMessage(error),
})}
</Alert>
)}
{hasFeature && !isError && (
<DestinationTable
destinations={data}
isLoading={isLoading}
onEdit={(destination) => {
setSelected(destination);
setFormOpened(true);
}}
onTest={handleTest}
onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })}
onDelete={(destination) => {
setSelected(destination);
setDeleteOpened(true);
}}
onToggle={(destination, enabled) =>
updateMutation.mutate({ destinationId: destination.id, enabled })
}
/>
)}
<DestinationFormModal
opened={formOpened}
onClose={() => setFormOpened(false)}
destination={selected}
/>
<DeleteDestinationModal
opened={deleteOpened}
onClose={() => setDeleteOpened(false)}
destination={selected}
/>
</>
);
}
@@ -1,179 +0,0 @@
import {
DATADOG_SITES,
ISiemDestination,
ISiemDestinationInput,
SiemDestinationType,
} from "@/ee/siem/types/siem.types";
export type DestinationFormValues = {
name: string;
type: SiemDestinationType;
url: string;
token: string;
apiKey: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
index: string;
source: string;
sourcetype: string;
host: string;
site: string;
service: string;
tags: string;
rejectUnauthorized: boolean;
enabled: boolean;
};
export const DEFAULT_FORM_VALUES: DestinationFormValues = {
name: "",
type: "splunk_hec",
url: "",
token: "",
apiKey: "",
authHeaderName: "Authorization",
authHeaderPrefix: "Bearer ",
format: "json",
index: "",
source: "docmost",
sourcetype: "docmost:audit",
host: "",
site: DATADOG_SITES[0],
service: "docmost",
tags: "",
rejectUnauthorized: true,
enabled: true,
};
const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/;
export const SECRET_MASK = "********";
export function initialValues(
destination?: ISiemDestination | null,
): DestinationFormValues {
if (!destination) return { ...DEFAULT_FORM_VALUES };
const config = destination.config as Record<string, any>;
const tls = config.tls ?? {};
return {
...DEFAULT_FORM_VALUES,
name: destination.name,
type: destination.type,
enabled: destination.enabled,
token: destination.hasSecrets?.token ? SECRET_MASK : "",
apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "",
url: config.url ?? "",
authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName,
authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix,
format: config.format ?? "json",
index: config.index ?? "",
source: config.source ?? DEFAULT_FORM_VALUES.source,
sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype,
host: config.host ?? "",
site: config.site ?? DEFAULT_FORM_VALUES.site,
service: config.service ?? DEFAULT_FORM_VALUES.service,
tags: config.tags ?? "",
rejectUnauthorized: tls.rejectUnauthorized ?? true,
};
}
function isValidUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
export function validateForm(
values: DestinationFormValues,
hasSecrets: Record<string, boolean> = {},
): Partial<Record<keyof DestinationFormValues, string>> {
const errors: Partial<Record<keyof DestinationFormValues, string>> = {};
if (!values.name.trim()) errors.name = "Name is required";
if (values.type !== "datadog" && !isValidUrl(values.url.trim())) {
errors.url = "Enter a valid http(s) URL";
}
if (values.type === "splunk_hec") {
if (!values.token && !hasSecrets.token) {
errors.token = "HEC token is required";
}
try {
const url = new URL(values.url.trim());
const path = url.pathname.replace(/\/+$/, "");
if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") {
errors.url = "Enter the HEC base URL or the /services/collector/event endpoint";
}
} catch {}
}
if (values.type === "datadog") {
if (!(DATADOG_SITES as readonly string[]).includes(values.site)) {
errors.site = "Select a Datadog site";
}
if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required";
}
if (values.type === "http") {
if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) {
errors.authHeaderName = "Use letters, digits and hyphens only";
}
}
return errors;
}
function enteredSecret(key: string, value: string): Record<string, string> {
const trimmed = value.trim();
return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {};
}
export function toPayload(values: DestinationFormValues): ISiemDestinationInput {
const tls = { rejectUnauthorized: values.rejectUnauthorized };
let config: Record<string, unknown>;
let secrets: Record<string, string>;
switch (values.type) {
case "splunk_hec":
config = {
url: values.url.trim(),
index: values.index.trim(),
source: values.source.trim() || "docmost",
sourcetype: values.sourcetype.trim() || "docmost:audit",
host: values.host.trim(),
tls,
};
secrets = enteredSecret("token", values.token);
break;
case "datadog":
config = {
site: values.site,
service: values.service.trim() || "docmost",
tags: values.tags.trim(),
};
secrets = enteredSecret("apiKey", values.apiKey);
break;
default:
config = {
url: values.url.trim(),
authHeaderName: values.authHeaderName.trim(),
authHeaderPrefix: values.authHeaderPrefix,
format: values.format,
tls,
};
secrets = enteredSecret("token", values.token);
}
return {
name: values.name.trim(),
type: values.type,
config,
secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")),
enabled: values.enabled,
};
}
@@ -1,115 +0,0 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
createSiemDestination,
deleteSiemDestination,
getSiemDestinations,
retrySiemDestination,
testSiemDestination,
updateSiemDestination,
} from "@/ee/siem/services/siem-service";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export const SIEM_DESTINATIONS_KEY = ["siem-destinations"];
export function extractErrorMessage(error: Error): string {
const data = (error as any)?.response?.data;
const message = data?.message ?? error.message;
return Array.isArray(message) ? message.join(", ") : String(message);
}
function showError(error: Error) {
notifications.show({ message: extractErrorMessage(error), color: "red" });
}
function isForbidden(error: unknown): boolean {
return (error as { response?: { status?: number } })?.response?.status === 403;
}
export function useSiemDestinationsQuery(
enabled = true,
): UseQueryResult<ISiemDestination[], Error> {
return useQuery({
queryKey: SIEM_DESTINATIONS_KEY,
queryFn: getSiemDestinations,
enabled,
retry: (failureCount, error) => !isForbidden(error) && failureCount < 2,
refetchInterval: (query) => (query.state.status === "error" ? false : 15_000),
});
}
function useInvalidateDestinations() {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY });
}
export function useCreateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, ISiemDestinationInput>({
mutationFn: createSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination created") });
invalidate();
},
onError: showError,
});
}
export function useUpdateSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<ISiemDestination, Error, IUpdateSiemDestinationInput>({
mutationFn: updateSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination updated") });
invalidate();
},
onError: showError,
});
}
export function useDeleteSiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: deleteSiemDestination,
onSuccess: () => {
notifications.show({ message: t("Destination deleted") });
invalidate();
},
onError: showError,
});
}
export function useRetrySiemDestinationMutation() {
const { t } = useTranslation();
const invalidate = useInvalidateDestinations();
return useMutation<void, Error, { destinationId: string }>({
mutationFn: retrySiemDestination,
onSuccess: () => {
notifications.show({ message: t("Retry scheduled") });
invalidate();
},
onError: showError,
});
}
export function useTestSiemDestinationMutation() {
return useMutation<ISiemTestResult, Error, ITestSiemDestinationInput>({
mutationFn: testSiemDestination,
onError: showError,
});
}
@@ -1,46 +0,0 @@
import api from "@/lib/api-client";
import {
ISiemDestination,
ISiemDestinationInput,
ISiemTestResult,
ITestSiemDestinationInput,
IUpdateSiemDestinationInput,
} from "@/ee/siem/types/siem.types";
export async function getSiemDestinations(): Promise<ISiemDestination[]> {
const req = await api.post<ISiemDestination[]>("/siem/destinations");
return req.data;
}
export async function createSiemDestination(
data: ISiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/create", data);
return req.data;
}
export async function updateSiemDestination(
data: IUpdateSiemDestinationInput,
): Promise<ISiemDestination> {
const req = await api.post<ISiemDestination>("/siem/destinations/update", data);
return req.data;
}
export async function deleteSiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/delete", data);
}
export async function testSiemDestination(
data: ITestSiemDestinationInput,
): Promise<ISiemTestResult> {
const req = await api.post<ISiemTestResult>("/siem/destinations/test", data);
return req.data;
}
export async function retrySiemDestination(data: {
destinationId: string;
}): Promise<void> {
await api.post("/siem/destinations/retry", data);
}
@@ -1,91 +0,0 @@
export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2;
export type SiemDestinationType = "http" | "splunk_hec" | "datadog";
export type SiemDestinationStatus = "healthy" | "failing";
export const DATADOG_SITES = [
"datadoghq.com",
"datadoghq.eu",
"us3.datadoghq.com",
"us5.datadoghq.com",
"ap1.datadoghq.com",
"ddog-gov.com",
] as const;
export interface ITlsOptions {
rejectUnauthorized: boolean;
}
export interface IHttpConfig {
url: string;
authHeaderName: string;
authHeaderPrefix: string;
format: "json" | "ndjson";
tls?: ITlsOptions;
}
export interface ISplunkHecConfig {
url: string;
index?: string;
source: string;
sourcetype: string;
host?: string;
channelId: string;
tls?: ITlsOptions;
}
export interface IDatadogConfig {
site: string;
service: string;
tags?: string;
}
export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig;
export interface ISiemDestination {
id: string;
name: string;
type: SiemDestinationType;
enabled: boolean;
status: SiemDestinationStatus;
config: ISiemConfig;
hasSecrets: Record<string, boolean>;
cursorCreatedAt: string;
lastDeliveredAt: string | null;
lastError: string | null;
lastErrorAt: string | null;
consecutiveFailures: number;
nextAttemptAt: string | null;
failingSince: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISiemDestinationInput {
name: string;
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface IUpdateSiemDestinationInput {
destinationId: string;
name?: string;
config?: Record<string, unknown>;
secrets?: Record<string, string>;
enabled?: boolean;
}
export interface ITestSiemDestinationInput {
type: SiemDestinationType;
config: Record<string, unknown>;
secrets?: Record<string, string>;
destinationId?: string;
}
export interface ISiemTestResult {
delivered: boolean;
error?: string;
statusCode?: number;
}
@@ -85,9 +85,6 @@ export function AudioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`audio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -121,14 +121,12 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`callout-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "bottom",
// offset: 233, // // offset: [0, 10],
// zIndex: 99,
flip: false,
}}
shouldShow={shouldShow}
@@ -257,9 +257,6 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey="columns-menu"
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -5,6 +5,10 @@ import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
import { queryClient } from "@/main.tsx";
import { Integration } from "@/features/integration/types/integration.types";
import {
getAttachmentInfo,
uploadFile,
@@ -22,6 +26,16 @@ const ATTACHMENT_NODE_TYPES = [
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
// Only installed providers get card treatment; anything else pastes as an
// ordinary link. The cache is prefetched when the page editor mounts;
// a cold cache also means ordinary link.
function isIntegrationInstalled(provider: string): boolean {
const installed = queryClient.getQueryData<Integration[]>([
"installed-integrations",
]);
return Boolean(installed?.some((i) => i.type === provider));
}
export const handlePaste = (
editor: Editor,
event: ClipboardEvent,
@@ -30,6 +44,59 @@ export const handlePaste = (
) => {
const clipboardData = event.clipboardData.getData("text/plain");
const integrationMatch = matchIntegrationLink(clipboardData.trim());
if (
integrationMatch &&
editor.state.selection.empty &&
isIntegrationInstalled(integrationMatch.provider)
) {
event.preventDefault();
const pastedUrl = clipboardData.trim();
editor
.chain()
.focus()
.setIntegrationLink({
url: pastedUrl,
provider: integrationMatch.provider,
})
// Anchor the "Paste as" menu to the inserted node, in the SAME
// transaction: BubbleMenu ignores meta-only transactions (it only
// re-evaluates when the doc or selection changed). Locate the node via
// the range this transaction's own steps touched, never by url, so a
// duplicate of the same link elsewhere in the doc can't steal the menu.
.command(({ tr }) => {
let start: number | null = null;
let end: number | null = null;
tr.mapping.maps.forEach((map, index) => {
const rest = tr.mapping.slice(index + 1);
map.forEach((_oldStart, _oldEnd, newStart, newEnd) => {
const mappedStart = rest.map(newStart, -1);
const mappedEnd = rest.map(newEnd, 1);
start = start === null ? mappedStart : Math.min(start, mappedStart);
end = end === null ? mappedEnd : Math.max(end, mappedEnd);
});
});
if (start === null || end === null) return true;
let pastedPos: number | null = null;
tr.doc.nodesBetween(
start,
Math.min(end, tr.doc.content.size),
(node, pos) => {
if (node.type.name === "integrationLink") {
pastedPos = pos;
}
},
);
if (pastedPos !== null) {
tr.setMeta(integrationPasteMenuKey, { pos: pastedPos });
}
return true;
})
.run();
return true;
}
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
@@ -273,9 +273,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -156,9 +156,6 @@ export function ImageMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -0,0 +1,49 @@
// Light-scheme text per hue, measured to pass 4.5:1 on the light-variant
// badge background; hexes are darkened .9 shades for hues whose scale
// never gets dark enough.
const BADGE_TEXT_LIGHT: Record<string, string> = {
dark: "var(--mantine-color-dark-9)",
gray: "var(--mantine-color-gray-9)",
red: "var(--mantine-color-red-9)",
pink: "var(--mantine-color-pink-9)",
grape: "var(--mantine-color-grape-9)",
violet: "var(--mantine-color-violet-9)",
indigo: "var(--mantine-color-indigo-9)",
blue: "var(--mantine-color-blue-9)",
cyan: "var(--mantine-color-cyan-9)",
teal: "var(--mantine-color-teal-9)",
green: "#277c38",
lime: "#4e7e0b",
yellow: "#ad5900",
orange: "#c3410e",
};
export function badgeTextColor(color?: string): string | undefined {
if (!color) return undefined;
const light = BADGE_TEXT_LIGHT[color];
if (!light) return undefined;
// Dark scheme keeps Mantine's own light-variant text.
return `light-dark(${light}, var(--mantine-color-${color}-light-color))`;
}
export function toBadgeColor(raw?: string): string {
if (!raw) return "gray";
const hex = raw.toLowerCase().replace("#", "");
if (/^[0-9a-f]{6}$/.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2 / 255;
if (max - min < 30) return l > 0.6 ? "gray" : "dark";
if (r > g && r > b) return g > 160 ? "orange" : "red";
if (g > r && g > b) return r > 160 ? "lime" : "green";
if (b > r && b > g) return r > 100 ? "violet" : "blue";
if (r > 200 && g > 200) return "yellow";
if (r > 200 && b > 200) return "pink";
if (g > 200 && b > 200) return "cyan";
return "gray";
}
return raw;
}
@@ -0,0 +1,65 @@
.card {
max-width: 100%;
cursor: pointer;
transition: background-color 150ms ease;
margin: 4px 0;
}
.card:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .card:hover {
background-color: var(--mantine-color-dark-5);
}
.thumbnail {
display: block;
width: 100%;
max-height: 320px;
object-fit: cover;
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .thumbnail {
background-color: var(--mantine-color-dark-6);
}
.mention {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
vertical-align: text-bottom;
text-decoration: none;
color: inherit;
border-radius: var(--mantine-radius-sm);
}
:global(.node-integrationMention) .mention {
border-bottom: none !important;
font-weight: 400;
}
.mention:hover {
background-color: var(--mantine-color-gray-0);
}
:global([data-mantine-color-scheme="dark"]) .mention:hover {
background-color: var(--mantine-color-dark-5);
}
.mentionText {
text-decoration: underline;
text-decoration-color: var(--mantine-color-gray-4);
text-underline-offset: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
font-weight: 500;
}
.mentionIcon {
flex-shrink: 0;
}
@@ -0,0 +1,525 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Card,
Group,
Text,
Badge,
Avatar,
Skeleton,
Anchor,
Stack,
Button,
} from "@mantine/core";
import { useCallback, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { timeAgo } from "@/lib/time";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
const SLACK_TEXT_CLAMP_LINES = 4;
function SlackMessageCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const meta = unfurlData.metadata ?? {};
const postedAt = meta.ts ? new Date(parseFloat(meta.ts) * 1000) : null;
const text: string = unfurlData.description ?? "";
const isLong =
text.length > 280 || text.split("\n").length > SLACK_TEXT_CLAMP_LINES;
const footer = [
meta.replyCount
? `${meta.replyCount} ${meta.replyCount === 1 ? t("reply") : t("replies")}`
: null,
unfurlData.status,
meta.teamName,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? "?").charAt(0)}
</Avatar>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.author}
</Text>
{postedAt && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeAgo(postedAt)}
</Text>
)}
</Group>
{text && (
<Text
size="sm"
lineClamp={expanded ? undefined : SLACK_TEXT_CLAMP_LINES}
style={{ whiteSpace: "pre-wrap" }}
>
{text}
</Text>
)}
{isLong && (
<Text
size="xs"
fw={600}
role="button"
tabIndex={0}
aria-expanded={expanded}
style={{ cursor: "pointer", width: "fit-content" }}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((v) => !v);
}
}}
>
{expanded ? t("show less") : t("show more")}
</Text>
)}
{footer && (
<Text size="xs" c="dimmed" truncate>
{footer}
</Text>
)}
</Stack>
<Anchor
href={url}
target="_blank"
rel="noopener"
aria-label={t("Open in Slack")}
style={{ flexShrink: 0, lineHeight: 0 }}
>
{getIntegrationIcon("slack", 18)}
</Anchor>
</Group>
</Card>
</NodeViewWrapper>
);
}
function JiraIssueCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
const meta = unfurlData.metadata ?? {};
const infoLine = [
meta.issueKey,
unfurlData.author
? t("Assigned to {{name}}", { name: unfurlData.author })
: t("Unassigned"),
meta.updatedAt
? t("Updated {{time}}", { time: timeAgo(new Date(meta.updatedAt)) })
: null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
/>
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon("jira", 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
<Group gap={4} wrap="nowrap">
{meta.issueTypeIconUrl && (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Text size="xs" c="dimmed" truncate>
{infoLine}
</Text>
</Group>
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("jira", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function FigmaFileCard({
url,
unfurlData,
}: {
url: string;
unfurlData: Record<string, any>;
}) {
const { t } = useTranslation();
// Figma thumbnail links are pre-signed and expire; drop the preview rather
// than render a broken image.
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const meta = unfurlData.metadata ?? {};
const thumbnailUrl: string | undefined = meta.thumbnailUrl;
const showThumbnail = Boolean(thumbnailUrl) && !thumbnailFailed;
const subtitle = [
unfurlData.author
? t("Last modified by {{name}}", { name: unfurlData.author })
: unfurlData.description,
meta.lastModified ? timeAgo(new Date(meta.lastModified)) : null,
]
.filter(Boolean)
.join(" • ");
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
{showThumbnail && (
<Card.Section withBorder>
<img
src={thumbnailUrl}
alt=""
loading="lazy"
className={classes.thumbnail}
onError={() => setThumbnailFailed(true)}
/>
</Card.Section>
)}
<Group gap="sm" wrap="nowrap" mt={showThumbnail ? "sm" : undefined}>
<Avatar
src={unfurlData.authorAvatarUrl}
size={28}
radius="xl"
style={{ flexShrink: 0 }}
>
{(unfurlData.author ?? unfurlData.title ?? "F").charAt(0)}
</Avatar>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{subtitle && (
<Text size="xs" c="dimmed" truncate>
{subtitle}
</Text>
)}
</Stack>
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon("figma", 18)}
</div>
</Group>
</Card>
</NodeViewWrapper>
);
}
function IntegrationLinkView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const [connecting, setConnecting] = useState(false);
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
if (needsConnection) {
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{needsConnection.title}
</Text>
{needsConnection.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{needsConnection.description}
</Text>
)}
</Stack>
{needsConnection.oauthConnect ? (
<Button
size="xs"
variant="filled"
color="dark"
loading={connecting}
onClick={handleConnect}
style={{ flexShrink: 0 }}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
) : (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("Use")} <code>/docmost help</code> {t("in")}{" "}
{needsConnection.integrationName} {t("to link your account.")}
</Text>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state === "loading") {
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Group gap="sm">
<Skeleton circle height={24} />
<Stack gap={4} style={{ flex: 1 }}>
<Skeleton height={14} width="60%" />
<Skeleton height={10} width="80%" />
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
if (unfurl.state !== "loaded") {
// anonymous or error: no third-party data, only what the url itself says
const described = describeIntegrationLink(url);
if (described) {
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
<div style={{ flexShrink: 0 }}>
{getIntegrationIcon(provider, 28)}
</div>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{described.title}
</Text>
{described.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{described.description}
</Text>
)}
</Stack>
</Group>
</Card>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle="">
<Card className={classes.card} withBorder padding="sm" radius="sm">
<Anchor href={url} target="_blank" rel="noopener" size="sm">
{url}
</Anchor>
</Card>
</NodeViewWrapper>
);
}
const unfurlData = unfurl.data;
// metadata.ts marks legacy message unfurls stored before metadata.type existed.
const slackMeta = provider === "slack" ? unfurlData.metadata : null;
if (slackMeta?.type === "message" || (slackMeta && !slackMeta.type && slackMeta.ts)) {
return <SlackMessageCard url={url} unfurlData={unfurlData} />;
}
if (provider === "jira" && unfurlData.metadata?.issueKey) {
return <JiraIssueCard url={url} unfurlData={unfurlData} />;
}
if (provider === "figma") {
return <FigmaFileCard url={url} unfurlData={unfurlData} />;
}
return (
<NodeViewWrapper data-drag-handle="">
<Card
className={classes.card}
withBorder
padding="sm"
radius="sm"
component="a"
href={url}
target="_blank"
rel="noopener"
style={{ textDecoration: "none", color: "inherit" }}
>
<Group gap="sm" wrap="nowrap">
{unfurlData.authorAvatarUrl ? (
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" style={{ flexShrink: 0 }} />
) : (
<div style={{ flexShrink: 0 }}>{getIntegrationIcon(provider, 28)}</div>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{unfurlData.title}
</Text>
{unfurlData.status && (
<Badge
size="xs"
variant="light"
color={toBadgeColor(unfurlData.statusColor)}
c={badgeTextColor(toBadgeColor(unfurlData.statusColor))}
style={{ flexShrink: 0 }}
>
{unfurlData.status}
</Badge>
)}
</Group>
{unfurlData.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{unfurlData.description}
</Text>
)}
</Stack>
{provider && (
<div style={{ flexShrink: 0, alignSelf: "center" }}>
{getIntegrationIcon(provider, 18)}
</div>
)}
</Group>
</Card>
</NodeViewWrapper>
);
}
export default memo(IntegrationLinkView);
@@ -0,0 +1,253 @@
import { NodeViewWrapper } from "@tiptap/react";
import {
Avatar,
Badge,
Button,
Group,
HoverCard,
Stack,
Text,
} from "@mantine/core";
import { memo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { describeIntegrationLink } from "@docmost/editor-ext";
import { getIntegrationIcon } from "@/features/integration/components/integration-icons";
import { getOAuthAuthorizeUrl } from "@/features/integration/services/integration-service";
import { useUnfurl } from "./use-unfurl";
import { badgeTextColor, toBadgeColor } from "./badge-color";
import classes from "./integration-link-view.module.css";
function shortUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.host}${parsed.pathname}`;
} catch {
return url;
}
}
function IntegrationMentionView(props: any) {
const { node } = props;
const { url, provider } = node.attrs;
const { t } = useTranslation();
const unfurl = useUnfurl(url);
const data = unfurl.state === "loaded" ? unfurl.data : null;
const needsConnection =
unfurl.state === "needsConnection" ? unfurl.needsConnection : null;
const [connecting, setConnecting] = useState(false);
const handleConnect = useCallback(
async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!needsConnection) return;
setConnecting(true);
try {
const result = await getOAuthAuthorizeUrl({
integrationId: needsConnection.integrationId,
returnPath: window.location.pathname,
});
window.location.href = result.authorizationUrl;
} catch (error) {
setConnecting(false);
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to start OAuth connection"),
color: "red",
});
}
},
[needsConnection, t],
);
const meta = data?.metadata ?? {};
const isSlackMessage =
provider === "slack" && (meta.type === "message" || (!meta.type && meta.ts));
const issueNumber = meta.iid ?? meta.number;
const typeLabel =
meta.type === "project"
? t("Project")
: meta.type === "initiative"
? t("Initiative")
: null;
const statusBadge = data?.status ? (
<Badge
size="xs"
variant="light"
color={toBadgeColor(data.statusColor)}
c={badgeTextColor(toBadgeColor(data.statusColor))}
className={classes.mentionIcon}
>
{data.status}
</Badge>
) : null;
let content;
if (!data) {
// anonymous / loading / error / needs-connection: a compact link chip.
// With no unfurl response, fall back to what the url text itself says.
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>
{needsConnection?.title ??
describeIntegrationLink(url)?.title ??
shortUrl(url)}
</span>
</>
);
} else if (isSlackMessage) {
content = (
<>
<Avatar
src={data.authorAvatarUrl}
size={16}
radius="xl"
className={classes.mentionIcon}
>
{(data.author ?? "?").charAt(0)}
</Avatar>
{data.author && (
<Text component="span" size="sm" c="dimmed">
{data.author}
</Text>
)}
<span className={classes.mentionText}>
{(data.description ?? "").split("\n")[0] || shortUrl(url)}
</span>
{getIntegrationIcon("slack", 14)}
{data.status && (
<Badge
size="xs"
variant="light"
color="gray"
c={badgeTextColor("gray")}
tt="none"
className={classes.mentionIcon}
>
{data.status}
</Badge>
)}
</>
);
} else if (meta.issueKey) {
// Jira: type icon leads, provider icon trails.
content = (
<>
{meta.issueTypeIconUrl ? (
<img
src={meta.issueTypeIconUrl}
width={14}
height={14}
alt=""
className={classes.mentionIcon}
/>
) : (
getIntegrationIcon(provider, 14)
)}
<Text component="span" size="sm" c="dimmed">
{meta.issueKey}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
{meta.issueTypeIconUrl && getIntegrationIcon(provider, 14)}
</>
);
} else if (issueNumber) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
#{issueNumber}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else if (typeLabel) {
content = (
<>
{getIntegrationIcon(provider, 14)}
<Text component="span" size="sm" c="dimmed">
{typeLabel}
</Text>
<span className={classes.mentionText}>{data.title}</span>
{statusBadge}
</>
);
} else {
content = (
<>
{getIntegrationIcon(provider, 14)}
<span className={classes.mentionText}>{data.title || shortUrl(url)}</span>
{statusBadge}
</>
);
}
const anchor = (
<a
href={url}
target="_blank"
rel="noopener"
title={url}
className={classes.mention}
>
{content}
</a>
);
return (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
{needsConnection ? (
<HoverCard
position="bottom-start"
shadow="md"
radius="md"
openDelay={150}
closeDelay={150}
>
<HoverCard.Target>{anchor}</HoverCard.Target>
<HoverCard.Dropdown p="sm">
<Stack gap="xs">
<Group gap={6} wrap="nowrap">
{getIntegrationIcon(provider, 16)}
<Text size="sm" fw={500}>
{needsConnection.integrationName}
</Text>
</Group>
{needsConnection.oauthConnect ? (
<Button
size="xs"
variant="filled"
color="dark"
leftSection={getIntegrationIcon(provider, 16)}
loading={connecting}
onClick={handleConnect}
>
{t("Connect to {{name}} to preview", {
name: needsConnection.integrationName,
})}
</Button>
) : (
<Text size="xs" c="dimmed">
{t("Use")} <code>/docmost help</code> {t("in")}{" "}
{needsConnection.integrationName}{" "}
{t("to link your account.")}
</Text>
)}
</Stack>
</HoverCard.Dropdown>
</HoverCard>
) : (
anchor
)}
</NodeViewWrapper>
);
}
export default memo(IntegrationMentionView);
@@ -0,0 +1,205 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
Paper,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { integrationPasteMenuKey } from "@/features/editor/extensions/integration-paste-menu";
const INTEGRATION_NODE_TYPES = ["integrationLink", "integrationMention"];
type PasteTarget = "card" | "mention" | "url";
const PASTE_OPTIONS: { target: PasteTarget; label: string }[] = [
{ target: "card", label: "Card" },
{ target: "mention", label: "Mention" },
{ target: "url", label: "URL" },
];
export function IntegrationPasteMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const [selectedIndex, setSelectedIndex] = useState(0);
const menuState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) return null;
return integrationPasteMenuKey.getState(ctx.editor.state) ?? null;
},
});
const findTarget = useCallback(() => {
const state = integrationPasteMenuKey.getState(editor.state);
if (!state) return null;
const node = editor.state.doc.nodeAt(state.pos);
if (!node || !INTEGRATION_NODE_TYPES.includes(node.type.name)) return null;
return { node, pos: state.pos };
}, [editor]);
const shouldShow = useCallback(() => Boolean(findTarget()), [findTarget]);
const getReferencedVirtualElement = useCallback(() => {
const target = findTarget();
if (!target) return undefined;
const dom = editor.view.nodeDOM(target.pos) as HTMLElement | null;
const domRect =
dom?.getBoundingClientRect?.() ??
posToDOMRect(
editor.view,
target.pos,
target.pos + target.node.nodeSize,
);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor, findTarget]);
const dismiss = useCallback(() => {
editor.view.dispatch(
editor.state.tr.setMeta(integrationPasteMenuKey, null),
);
}, [editor]);
const convert = useCallback(
(target: PasteTarget) => {
const found = findTarget();
if (!found) {
dismiss();
return;
}
const { node, pos } = found;
const attrs = { ...node.attrs };
const from = pos;
const to = pos + node.nodeSize;
const isBlock = node.type.name === "integrationLink";
// Always replace, even when the node is already in the requested form:
// the menu closes via the doc change, and BubbleMenu never re-evaluates
// on meta-only transactions, so a bare dismiss would leave it stuck.
let content: Record<string, any>;
if (target === "card") {
content = { type: "integrationLink", attrs };
} else if (target === "mention") {
const mention = { type: "integrationMention", attrs };
content = isBlock
? {
type: "paragraph",
content: [mention, { type: "text", text: " " }],
}
: mention;
} else {
const linkText = {
type: "text",
text: attrs.url,
marks: [{ type: "link", attrs: { href: attrs.url } }],
};
content = isBlock
? { type: "paragraph", content: [linkText] }
: linkText;
}
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.deleteRange({ from, to })
.insertContentAt(from, content)
.run();
},
[editor, findTarget, dismiss],
);
useEffect(() => {
if (menuState) setSelectedIndex(0);
}, [menuState]);
// DOM focus stays in the editor (as with the slash menu); keys are handled
// here. Capture phase, because ProseMirror itself consumes arrow keys next
// to atom nodes (gap cursor / node selection) before they would bubble.
useEffect(() => {
if (!menuState) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
dismiss();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
const delta = event.key === "ArrowDown" ? 1 : -1;
setSelectedIndex(
(prev) =>
(prev + delta + PASTE_OPTIONS.length) % PASTE_OPTIONS.length,
);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
convert(PASTE_OPTIONS[selectedIndex].target);
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [menuState, dismiss, convert, selectedIndex]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey="integration-paste-menu"
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{ placement: "bottom-start", flip: true }}
shouldShow={shouldShow}
>
{/* Content is gated on the plugin state too: meta-only dismissals
(Escape) are invisible to BubbleMenu's update cycle. */}
{menuState ? (
<Paper
shadow="md"
radius="md"
withBorder
p={4}
miw={140}
role="listbox"
aria-label={t("Paste as")}
aria-activedescendant={`integration-paste-option-${PASTE_OPTIONS[selectedIndex].target}`}
>
<VisuallyHidden role="status" aria-live="polite" aria-atomic="true">
{t(PASTE_OPTIONS[selectedIndex].label)}
</VisuallyHidden>
<Text size="xs" c="dimmed" px={8} py={4}>
{t("Paste as")}
</Text>
<Stack gap={2}>
{PASTE_OPTIONS.map((option, index) => (
<Button
key={option.target}
id={`integration-paste-option-${option.target}`}
role="option"
aria-selected={index === selectedIndex}
variant={index === selectedIndex ? "light" : "subtle"}
color="gray"
size="compact-sm"
fullWidth
justify="flex-start"
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => convert(option.target)}
>
{t(option.label)}
</Button>
))}
</Stack>
</Paper>
) : null}
</BaseBubbleMenu>
);
}
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import { ReactNode } from "react";
import { useUnfurl } from "./use-unfurl";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
vi.mock("@/features/integration/services/integration-service", () => ({
unfurlUrl: vi.fn(),
}));
const mockedUnfurlUrl = vi.mocked(unfurlUrl);
const ISSUE_URL = "https://github.com/acme/repo/issues/42";
const loadedResult: UnfurlResult = {
title: "Fix race condition in file watcher",
url: ISSUE_URL,
provider: "github",
status: "open",
};
const needsConnectionResult: UnfurlNeedsConnection = {
needsConnection: true,
integrationId: "int-1",
integrationType: "github",
integrationName: "GitHub",
oauthConnect: true,
title: "GitHub link",
description: "github.com/acme/repo/issues/42",
};
function createWrapper(loggedIn: boolean) {
const store = createStore();
if (loggedIn) {
store.set(currentUserAtom, { user: { id: "user-1" } } as any);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: ReactNode }) => (
<Provider store={store}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Provider>
);
}
describe("useUnfurl", () => {
beforeEach(() => {
localStorage.clear();
vi.resetAllMocks();
});
it("never fetches for anonymous viewers and reports anonymous", () => {
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(false),
});
expect(result.current.state).toBe("anonymous");
expect(mockedUnfurlUrl).not.toHaveBeenCalled();
});
it("starts loading then exposes the unfurl result", async () => {
mockedUnfurlUrl.mockResolvedValue(loadedResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
expect(result.current.state).toBe("loading");
await waitFor(() => expect(result.current.state).toBe("loaded"));
expect(
result.current.state === "loaded" && result.current.data,
).toEqual(loadedResult);
expect(mockedUnfurlUrl).toHaveBeenCalledWith({ url: ISSUE_URL });
});
it("maps a needsConnection response without treating it as an error", async () => {
mockedUnfurlUrl.mockResolvedValue(needsConnectionResult);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() =>
expect(result.current.state).toBe("needsConnection"),
);
expect(
result.current.state === "needsConnection" &&
result.current.needsConnection,
).toEqual(needsConnectionResult);
});
it("maps a null result (no matching provider) to error", async () => {
mockedUnfurlUrl.mockResolvedValue(null);
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
it("maps a rejected request to error", async () => {
mockedUnfurlUrl.mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useUnfurl(ISSUE_URL), {
wrapper: createWrapper(true),
});
await waitFor(() => expect(result.current.state).toBe("error"));
});
});
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { unfurlUrl } from "@/features/integration/services/integration-service";
import {
UnfurlNeedsConnection,
UnfurlResult,
} from "@/features/integration/types/integration.types";
const UNFURL_STALE_TIME = 5 * 60 * 1000; // mirrors the server-side Redis TTL
export type UnfurlState =
| { state: "anonymous" }
| { state: "loading" }
| { state: "error" }
| { state: "needsConnection"; needsConnection: UnfurlNeedsConnection }
| { state: "loaded"; data: UnfurlResult };
// Resolves the unfurl per viewer at render time. Nothing is written back into
// the document, so third-party permissions are enforced on every view:
// unconnected viewers get needsConnection and anonymous viewers never fetch.
export function useUnfurl(url: string): UnfurlState {
const currentUser = useAtomValue(currentUserAtom);
const isAuthenticated = Boolean(currentUser?.user);
const query = useQuery({
queryKey: ["unfurl", url],
queryFn: () => unfurlUrl({ url }),
enabled: isAuthenticated && Boolean(url),
staleTime: UNFURL_STALE_TIME,
retry: false,
});
if (!isAuthenticated || !url) {
return { state: "anonymous" };
}
if (query.isPending) {
return { state: "loading" };
}
if (query.isError || !query.data) {
return { state: "error" };
}
if ("needsConnection" in query.data) {
return { state: "needsConnection", needsConnection: query.data };
}
return { state: "loaded", data: query.data };
}
@@ -26,12 +26,7 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
@@ -65,10 +60,9 @@ export default function LinkView(props: MarkViewProps) {
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
const { shareId, spaceSlug, pageSlug } = useParams();
const { shareId, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
@@ -90,33 +84,16 @@ export default function LinkView(props: MarkViewProps) {
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
pageId:
isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute
? slugId
: null,
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
// Resolved eagerly (not gated on the popover): an unresolvable target must
// render as inert text rather than a link that dead-ends at /login.
const { data: publicSpacePageData } = usePublicSpacePageQuery({
spaceSlug: isPublicSpaceRoute && slugId ? spaceSlug : undefined,
pageSlugId: slugId,
contentless: true,
});
const isUnresolvedPublicLink =
isPublicSpaceRoute && isInternal && !publicSpacePageData?.page && !slugId;
let pageTitle = linkedPage?.title;
if (isShareRoute) {
pageTitle = sharedPageData?.page?.title;
} else if (isPublicSpaceRoute) {
pageTitle = publicSpacePageData?.page?.title;
}
const pageTitle = isShareRoute
? sharedPageData?.page?.title
: linkedPage?.title;
const pendingTitleRef = useRef<string | null>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
@@ -283,27 +260,6 @@ export default function LinkView(props: MarkViewProps) {
anchorId: anchor || undefined,
});
navigate(sharedUrl);
} else if (isPublicSpaceRoute) {
if (slugId && publicSpacePageData?.page) {
navigate(
buildPublicSpaceUrl({
// cross-space targets resolve to their own space's public URL
spaceSlug: publicSpacePageData.space?.slug ?? spaceSlug,
pageSlugId: slugId,
pageTitle: pageTitle,
anchorId: anchor || undefined,
}),
);
} else if (slugId) {
// no public URL: the /p/ resolver redirects members straight to the
// page and funnels anonymous visitors through login first; a new tab
// keeps the docs tab's history intact through that redirect chain
window.open(
buildPageUrl(undefined, slugId, pageTitle, anchor || undefined),
"_blank",
"noopener,noreferrer",
);
}
} else {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
}
@@ -320,11 +276,8 @@ export default function LinkView(props: MarkViewProps) {
location.pathname,
isInternal,
isShareRoute,
isPublicSpaceRoute,
slugId,
shareId,
spaceSlug,
publicSpacePageData,
pageTitle,
pageSlug,
]);
@@ -366,18 +319,12 @@ export default function LinkView(props: MarkViewProps) {
setPopoverState("closed");
}, [editor]);
const internalHref = () => {
if (isShareRoute && slugId) {
return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle });
}
if (isPublicSpaceRoute && slugId && publicSpacePageData?.page) {
return buildPublicSpaceUrl({ spaceSlug, pageSlugId: slugId, pageTitle });
}
return href;
};
const displayHref = sanitizeUrl(
isInternal ? internalHref() : normalizeUrl(href),
isInternal
? isShareRoute && slugId
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
: href
: normalizeUrl(href),
);
const linkTitleInput = (
@@ -427,16 +374,6 @@ export default function LinkView(props: MarkViewProps) {
</>
);
// Targets outside the published space have no public URL, so the label is
// rendered as inert text instead of a link that dead-ends at /login.
if (isUnresolvedPublicLink) {
return (
<span ref={wrapperRef} className={classes.linkWrapper}>
<MarkViewContent />
</span>
);
}
return (
<Popover
opened={isPopoverVisible}
@@ -4,10 +4,8 @@ import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
@@ -23,30 +21,17 @@ export default function MentionView(props: NodeViewProps) {
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const {
data: page,
isLoading,
isError,
} = usePageQuery({
pageId:
isPageMention && !isShareRoute && !isPublicSpaceRoute ? slugId : null,
});
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
const { data: sharedPage } = useSharePageQuery({
pageId: isPageMention && isShareRoute ? slugId : undefined,
});
// Without the slugId guard the request resolves to the space home, which
// would render a mention pointing at the wrong page.
const { data: publicPageData } = usePublicSpacePageQuery({
spaceSlug:
isPageMention && isPublicSpaceRoute && slugId ? spaceSlug : undefined,
pageSlugId: slugId,
contentless: true,
});
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
@@ -102,61 +87,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{isPageMention && isPublicSpaceRoute && publicPageData?.page && (
<Anchor
component={Link}
fw={500}
to={buildPublicSpaceUrl({
spaceSlug: publicPageData.space?.slug ?? spaceSlug,
pageSlugId: slugId,
pageTitle: publicPageData.page.title || label,
anchorId,
})}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{publicPageData.page.title || label}
</span>
</Anchor>
)}
{/* No public URL: the /p/ resolver redirects members to the page and
funnels anonymous visitors through login first. New tab, so the
redirect chain never rewrites the docs tab's history. */}
{isPageMention && isPublicSpaceRoute && !publicPageData?.page && (
<Anchor
fw={500}
href={buildPageUrl(undefined, slugId, label, anchorId)}
target="_blank"
rel="noopener noreferrer"
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>{label}</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && (
{isPageMention && !isShareRoute && isError && (
<Anchor
component={Link}
fw={500}
@@ -180,7 +111,7 @@ export default function MentionView(props: NodeViewProps) {
</Anchor>
)}
{isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && (
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
@@ -106,9 +106,6 @@ export function PdfMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`pdf-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
}
// Clear search term in editor
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
editor.commands.setSearchTerm("");
}
};
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
useEffect(() => {
if (!isEditorReady(editor)) return;
editor.commands.setSearchTerms([searchText]);
editor.commands.setSearchTerm(searchText);
editor.commands.resetIndex();
editor.commands.selectCurrentItem();
}, [searchText]);
@@ -181,10 +181,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
const location = useLocation();
useEffect(() => {
if (pageFindState.isOpen) {
closeDialog();
}
}, [location.pathname]);
closeDialog();
}, [location]);
return (
<Dialog
@@ -1,205 +0,0 @@
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
import {
IconArrowNarrowDown,
IconArrowNarrowUp,
IconX,
} from "@tabler/icons-react";
import { useEditor } from "@tiptap/react";
import { isEditorReady } from "@docmost/editor-ext";
import React, { useCallback, useEffect, useRef, useState } from "react";
import classes from "./search-replace.module.css";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
interface SearchNavigationDialogProps {
editor: ReturnType<typeof useEditor>;
}
interface SearchNavigationEvent extends CustomEvent {
detail: {
searchTerms: string[];
wholeWord?: boolean;
};
}
function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const openRef = useRef(false);
const [resultState, setResultState] = useState({
resultIndex: 0,
resultsLength: 0,
});
const goToSelection = () => {
if (!isEditorReady(editor)) return;
const { results, resultIndex } = editor.storage.searchAndReplace;
const position = results[resultIndex];
setResultState({
resultsLength: results.length,
resultIndex,
});
if (!position) return;
requestAnimationFrame(() => {
document
.querySelector(".search-result-current")
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
};
const next = () => {
if (!isEditorReady(editor)) return;
editor.commands.nextSearchResult();
goToSelection();
};
const previous = () => {
if (!isEditorReady(editor)) return;
editor.commands.previousSearchResult();
goToSelection();
};
const close = useCallback(() => {
if (!openRef.current) return;
openRef.current = false;
setOpen(false);
if (isEditorReady(editor)) {
editor.commands.setSearchTerms([""]);
}
const nextParams = new URLSearchParams(location.search);
nextParams.delete("q");
nextParams.delete("m");
const nextSearch = nextParams.toString();
navigate(
{
pathname: location.pathname,
search: nextSearch ? `?${nextSearch}` : "",
hash: location.hash,
},
{ replace: true },
);
}, [editor, location.hash, location.pathname, location.search, navigate]);
useEffect(() => {
const handleOpen = (event: Event) => {
const { searchTerms: terms, wholeWord = true } = (
event as SearchNavigationEvent
).detail;
if (!terms?.length || !isEditorReady(editor)) return;
openRef.current = false;
editor.commands.setSearchTerms(terms);
editor.commands.setWholeWord(wholeWord);
editor.commands.resetIndex();
const { results, resultIndex } = editor.storage.searchAndReplace;
openRef.current = true;
if (results.length === 0) {
close();
return;
}
setOpen(true);
setResultState({
resultIndex,
resultsLength: results.length,
});
goToSelection();
};
const handleClose = () => {
if (openRef.current) {
close();
}
};
document.addEventListener("openSearchNavigationDialog", handleOpen);
document.addEventListener("openFindDialogFromEditor", handleClose);
document.addEventListener("closeFindDialogFromEditor", handleClose);
return () => {
document.removeEventListener("openSearchNavigationDialog", handleOpen);
document.removeEventListener("openFindDialogFromEditor", handleClose);
document.removeEventListener("closeFindDialogFromEditor", handleClose);
};
}, [close, editor]);
useEffect(() => {
const handleTransaction = () => {
if (!openRef.current || editor.isDestroyed) return;
const { results } = editor.storage.searchAndReplace;
if (results.length === 0) {
close();
}
};
editor.on("transaction", handleTransaction);
return () => {
editor.off("transaction", handleTransaction);
};
}, [close, editor]);
return (
<Dialog
className={classes.findDialog}
opened={open}
size="xs"
radius="md"
w="auto"
position={{ top: 90, right: 50 }}
withBorder
aria-label="Search navigation"
>
<Flex align="center" gap="xs">
<Text size="xs" style={{ flex: 1 }}>
{resultState.resultsLength > 0
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
: t("Not found")}
</Text>
<Tooltip label="Previous match">
<ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label="Previous match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Next match">
<ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label="Next match"
disabled={resultState.resultsLength === 0}
>
<IconArrowNarrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Close">
<ActionIcon
variant="subtle"
color="gray"
onClick={close}
aria-label="Close"
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
</Flex>
</Dialog>
);
}
export default SearchNavigationDialog;
@@ -1,47 +0,0 @@
import { useEffect, useRef } from "react";
import type { useEditor } from "@tiptap/react";
interface UseSearchNavigationParamsProps {
editor: ReturnType<typeof useEditor>;
isSynced: boolean;
pageId: string;
searchParams: URLSearchParams;
showStatic: boolean;
}
export function useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
}: UseSearchNavigationParamsProps) {
const appliedSearchKeyRef = useRef<string | null>(null);
const searchKey = `${pageId}:${searchParams.toString()}`;
useEffect(() => {
const searchQueries = searchParams.getAll("q");
if (!searchQueries.length) {
appliedSearchKeyRef.current = null;
return;
}
if (
!editor ||
editor.isDestroyed ||
!editor.view.dom.isConnected ||
appliedSearchKeyRef.current === searchKey
) {
return;
}
const match = searchParams.get("m");
appliedSearchKeyRef.current = searchKey;
document.dispatchEvent(
new CustomEvent("openSearchNavigationDialog", {
detail: { searchTerms: searchQueries, wholeWord: match === "whole" },
}),
);
}, [editor, isSynced, searchKey, searchParams, showStatic]);
}
@@ -61,9 +61,6 @@ export const SubpagesMenu = React.memo(
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
shouldShow={shouldShow}
>
@@ -3,30 +3,22 @@ import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useLocation, useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildPublicSpaceUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
import { useAtomValue } from "jotai";
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
import { findSubpagesInTree } from "@/features/share/utils";
import { extractPageSlugId } from "@/lib";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId, pageSlug } = useParams();
const { t } = useTranslation();
const location = useLocation();
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
// @ts-ignore
const storagePageId = editor.storage.pageId;
@@ -37,25 +29,11 @@ export default function SubpagesView(props: NodeViewProps) {
currentPageId = routePageId;
}
// Public docs must resolve the page from the route, not editor storage:
// storage.pageId is set after this view's first render and is not reactive,
// which froze the list at "No subpages" until something re-rendered it. The
// space home renders at the bare URL, so it falls back to the first root.
if (isPublicSpaceRoute) {
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
}
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const publicSpaceSubpages = useMemo(
() => findSubpagesInTree(publicSpaceTreeData, currentPageId),
[publicSpaceTreeData, currentPageId],
);
const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
const { data, isLoading, error } = useGetSidebarPagesQuery(
isPublicView ? null : { pageId: currentPageId },
shareId ? null : { pageId: currentPageId },
);
const subpages = useMemo(() => {
@@ -70,33 +48,17 @@ export default function SubpagesView(props: NodeViewProps) {
}));
}
if (isPublicSpaceRoute) {
return publicSpaceSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [
data,
shareId,
sharedSubpages,
isPublicSpaceRoute,
publicSpaceSubpages,
]);
}, [data, shareId, sharedSubpages]);
if (isLoading && !isPublicView) {
if (isLoading && !shareId) {
return null;
}
if (error && !isPublicView) {
if (error && !shareId) {
return (
<NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md">
@@ -134,13 +96,7 @@ export default function SubpagesView(props: NodeViewProps) {
pageSlugId: page.slugId,
pageTitle: page.title,
})
: isPublicSpaceRoute
? buildPublicSpaceUrl({
spaceSlug,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
@@ -18,7 +18,7 @@ export type HeadingLink = {
position: number;
};
export const recalculateLinks = (nodePos: NodePos[]) => {
const recalculateLinks = (nodePos: NodePos[]) => {
const nodes: HTMLElement[] = [];
const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>(
@@ -9,7 +9,6 @@ import React, {
} from "react";
import {
lookupTransclusion,
lookupTransclusionForPublicSpace,
lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
@@ -39,7 +38,6 @@ const TransclusionLookupContext = createContext<ContextValue | null>(null);
export function TransclusionLookupProvider({
children,
shareId,
spaceSlug,
}: {
children: React.ReactNode;
/**
@@ -49,11 +47,6 @@ export function TransclusionLookupProvider({
* app, where personal permissions gate access.
*/
shareId?: string;
/**
* When set, lookups go through the public-space endpoint and are gated by
* the published space. Used by the public docs viewer.
*/
spaceSlug?: string;
}) {
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
const queueRef = useRef(new Set<LookupKey>());
@@ -62,8 +55,6 @@ export function TransclusionLookupProvider({
// memoized callbacks (and thus doesn't re-render every consumer).
const shareIdRef = useRef<string | undefined>(shareId);
shareIdRef.current = shareId;
const spaceSlugRef = useRef<string | undefined>(spaceSlug);
spaceSlugRef.current = spaceSlug;
// Last looked-up value for each key. Re-subscribers (e.g. when the editor
// remounts after switching from static to live) get this immediately
// instead of triggering a duplicate fetch.
@@ -100,18 +91,12 @@ export function TransclusionLookupProvider({
try {
const activeShareId = shareIdRef.current;
const activeSpaceSlug = spaceSlugRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
: activeSpaceSlug
? await lookupTransclusionForPublicSpace({
spaceSlug: activeSpaceSlug,
references,
})
: await lookupTransclusion({ references });
: await lookupTransclusion({ references });
for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r);
@@ -132,9 +132,6 @@ export function VideoMenu({ editor }: EditorMenuProps) {
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu`}
ref={(element) => {
if (element) element.style.zIndex = "99";
}}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
@@ -1,112 +0,0 @@
import { Editor, Node } from "@tiptap/core";
import { GapCursor } from "@tiptap/pm/gapcursor";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import { StarterKit } from "@tiptap/starter-kit";
import { describe, expect, it } from "vitest";
import { TiptapDocument } from "./document";
const Footnotes = Node.create({
name: "footnotes",
group: "",
content: "paragraph*",
isolating: true,
renderHTML() {
return ["ol", { class: "footnotes" }, 0];
},
});
const AtomBlock = Node.create({
name: "atomBlock",
group: "block",
atom: true,
renderHTML() {
return ["div", { "data-atom-block": "" }];
},
});
const IsolatingBlock = Node.create({
name: "isolatingBlock",
group: "block",
content: "paragraph+",
isolating: true,
renderHTML() {
return ["div", { "data-isolating-block": "" }, 0];
},
});
function createEditor(content: object[]) {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [
TiptapDocument,
StarterKit.configure({ document: false }),
Footnotes,
AtomBlock,
IsolatingBlock,
],
content: { type: "doc", content },
});
}
function pressKey(editor: Editor, key: string, keyCode: number) {
editor.view.dom.dispatchEvent(
new KeyboardEvent("keydown", {
key,
keyCode,
bubbles: true,
cancelable: true,
}),
);
}
describe("TiptapDocument", () => {
it("stops on the gap when arrowing down from a selected block node", () => {
const editor = createEditor([
{ type: "atomBlock" },
{ type: "atomBlock" },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
editor.view.dispatch(
editor.state.tr.setSelection(
NodeSelection.create(editor.state.doc, 0),
),
);
pressKey(editor, "ArrowDown", 40);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
it("stops on the gap when arrowing right out of an isolating block", () => {
const paragraph = (text: string) => ({
type: "paragraph",
content: [{ type: "text", text }],
});
const editor = createEditor([
{ type: "isolatingBlock", content: [paragraph("a")] },
{ type: "isolatingBlock", content: [paragraph("b")] },
{ type: "paragraph" },
]);
const gapPos = editor.state.doc.child(0).nodeSize;
const endOfFirstText = gapPos - 2;
editor.view.dispatch(
editor.state.tr.setSelection(
TextSelection.create(editor.state.doc, endOfFirstText),
),
);
pressKey(editor, "ArrowRight", 39);
expect(editor.state.selection).toBeInstanceOf(GapCursor);
expect(editor.state.selection.head).toBe(gapPos);
editor.destroy();
});
});
@@ -1,8 +0,0 @@
import { Document } from "@tiptap/extension-document";
// With `block+ footnotes?`, ProseMirror's defaultType after the first block is
// `footnotes` (not a textblock), so GapCursor.valid() rejects every top-level gap.
export const TiptapDocument = Document.extend({
content: "block+ footnotes?",
allowGapCursor: true,
});
@@ -1,6 +1,6 @@
import { markInputRule } from "@tiptap/core";
import { StarterKit } from "@tiptap/starter-kit";
import { TiptapDocument } from "@/features/editor/extensions/document";
import { Document } from "@tiptap/extension-document";
import { Code } from "@tiptap/extension-code";
import { TextAlign } from "@tiptap/extension-text-align";
import { TaskList, TaskItem } from "@tiptap/extension-list";
@@ -57,6 +57,8 @@ import {
Indent,
UniqueID,
SharedStorage,
IntegrationLink,
IntegrationMention,
Columns,
Column,
Status,
@@ -95,6 +97,9 @@ import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-v
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
import IntegrationMentionView from "@/features/editor/components/integration-link/integration-mention-view.tsx";
import { IntegrationPasteMenuExtension } from "@/features/editor/extensions/integration-paste-menu";
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
import { BaseEmbedView } from "@/features/editor/components/base-embed/base-embed-view.tsx";
@@ -148,7 +153,9 @@ export const mainExtensions = [
codeBlock: false,
code: false,
}),
TiptapDocument,
Document.extend({
content: "block+ footnotes?",
}),
// Override TipTap's Code extension to fix the inline code input rule.
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
// before the opening backtick as part of the match, causing markInputRule
@@ -385,6 +392,13 @@ export const mainExtensions = [
Subpages.configure({
view: SubpagesView,
}),
IntegrationLink.configure({
view: IntegrationLinkView,
}),
IntegrationMention.configure({
view: IntegrationMentionView,
}),
IntegrationPasteMenuExtension,
Status.configure({
view: StatusView,
}),
@@ -0,0 +1,44 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export type IntegrationPasteMenuState = { pos: number } | null;
export const integrationPasteMenuKey = new PluginKey<IntegrationPasteMenuState>(
"integrationPasteMenu",
);
// Holds the position of a just-pasted integration node so the "Paste as"
// menu can anchor to it. Any other edit or selection change dismisses it.
export const IntegrationPasteMenuExtension = Extension.create({
name: "integrationPasteMenu",
addProseMirrorPlugins() {
return [
new Plugin({
key: integrationPasteMenuKey,
state: {
init: (): IntegrationPasteMenuState => null,
apply(tr, prev): IntegrationPasteMenuState {
const meta = tr.getMeta(integrationPasteMenuKey);
if (meta !== undefined) return meta;
if (!prev) return null;
// Clicking or typing elsewhere dismisses.
if (tr.selectionSet) return null;
// Structural follow-ups (unique-id assignment, trailing node)
// keep the menu anchored: remap and re-validate the position.
if (tr.docChanged) {
const pos = tr.mapping.map(prev.pos);
const node = tr.doc.nodeAt(pos);
const isIntegrationNode =
node &&
(node.type.name === "integrationLink" ||
node.type.name === "integrationMention");
return isIntegrationNode ? { pos } : null;
}
return prev;
},
},
}),
];
},
});
+16 -13
View File
@@ -65,13 +65,11 @@ import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import SearchNavigationDialog from "@/features/editor/components/search-and-replace/search-navigation-dialog.tsx";
import { useSearchNavigationParams } from "@/features/editor/components/search-and-replace/use-search-navigation-params.ts";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams, useSearchParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
@@ -81,6 +79,8 @@ import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { IntegrationPasteMenu } from "@/features/editor/components/integration-link/integration-paste-menu.tsx";
import { getInstalledIntegrations } from "@/features/integration/services/integration-service";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
import { useTranslation } from "react-i18next";
import {
@@ -201,7 +201,6 @@ function CollabPageEditor({
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const [searchParams] = useSearchParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const canScroll = useCallback(
@@ -361,6 +360,18 @@ function CollabPageEditor({
[pageId, editable, extensions],
);
useEffect(() => {
// Warm the cache the paste handler reads to decide whether a pasted
// integration url becomes a card or stays an ordinary link. gcTime
// Infinity: no observer holds this query, and the default 5-minute gc
// would evict it mid-session, silently downgrading pastes to plain links.
queryClient.prefetchQuery({
queryKey: ["installed-integrations"],
queryFn: getInstalledIntegrations,
gcTime: Infinity,
});
}, []);
useLayoutEffect(() => {
if (editor && !editor.isDestroyed) {
// @ts-ignore
@@ -440,14 +451,6 @@ function CollabPageEditor({
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useSearchNavigationParams({
editor,
isSynced,
pageId,
searchParams,
showStatic,
});
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
@@ -471,7 +474,6 @@ function CollabPageEditor({
{editor && (
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && <SearchNavigationDialog editor={editor} />}
{editor && editorIsEditable && (
<div>
@@ -488,6 +490,7 @@ function CollabPageEditor({
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<ColumnsMenu editor={editor} />
<IntegrationPasteMenu editor={editor} />
</div>
)}
{editor && !editorIsEditable && (editable || canComment) && (
@@ -35,16 +35,6 @@ interface PageEditorProps {
* that isn't itself shared.
*/
shareId?: string;
/**
* When rendering inside a public space, pass the space slug. Transclusion
* lookups then resolve against the published space instead of the viewer's
* personal permissions.
*/
spaceSlug?: string;
/** Rendered between the title and the content. */
byline?: React.ReactNode;
/** Set false when the consumer renders its own end matter (e.g. prev/next). */
trailingSpace?: boolean;
}
export default function ReadonlyPageEditor({
@@ -53,16 +43,12 @@ export default function ReadonlyPageEditor({
pageId,
printMode = false,
shareId,
spaceSlug,
byline,
trailingSpace = true,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
const [contentEditor, setContentEditor] = useState<Editor | null>(null);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
const isPublicView = Boolean(shareId || spaceSlug);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
@@ -78,9 +64,9 @@ export default function ReadonlyPageEditor({
}, []);
useEffect(() => {
if (!isPublicView) return;
if (!shareId) return;
setLightboxRequest(null);
}, [pageId, isPublicView]);
}, [pageId, shareId]);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
@@ -113,7 +99,7 @@ export default function ReadonlyPageEditor({
];
return (
<TransclusionLookupProvider shareId={shareId} spaceSlug={spaceSlug}>
<TransclusionLookupProvider shareId={shareId}>
<div className="page-title">
<EditorProvider
editable={false}
@@ -124,8 +110,6 @@ export default function ReadonlyPageEditor({
></EditorProvider>
</div>
{byline}
<EditorProvider
editable={false}
immediatelyRender={true}
@@ -133,7 +117,7 @@ export default function ReadonlyPageEditor({
extensions={extensions}
content={content}
editorProps={
isPublicView
shareId
? {
handleClickOn: (_view, _pos, node) => {
const request = getLightboxClickRequest(node);
@@ -160,7 +144,7 @@ export default function ReadonlyPageEditor({
}
}}
></EditorProvider>
{isPublicView && contentEditor && (
{shareId && contentEditor && (
<LightboxView
editor={contentEditor}
open={!!lightboxRequest}
@@ -169,7 +153,7 @@ export default function ReadonlyPageEditor({
onClose={() => setLightboxRequest(null)}
/>
)}
{trailingSpace && <div style={{ paddingBottom: "20vh" }}></div>}
<div style={{ paddingBottom: "20vh" }}></div>
</TransclusionLookupProvider>
);
}
@@ -12,19 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
export default function GroupMembersList() {
const { t } = useTranslation();
const { groupId } = useParams();
const { search, cursor, goNext, goPrev, handleSearch } =
usePaginateAndSearch();
const { data, isLoading } = useGroupMembersQuery(groupId, {
cursor,
query: search,
});
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
@@ -54,7 +48,6 @@ export default function GroupMembersList() {
return (
<>
<SearchInput onSearch={handleSearch} />
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
@@ -66,62 +59,55 @@ export default function GroupMembersList() {
</Table.Thead>
<Table.Tbody>
{data?.items.length > 0 ? (
data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={3} />
)}
{data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="gray"
aria-label={t("Member actions for {{name}}", {
name: user.name,
})}
>
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
@@ -0,0 +1,93 @@
import { Group, Text, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IntegrationDefinition, UserConnection } from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
type ConnectionRowProps = {
definition: IntegrationDefinition;
connection?: UserConnection;
onConnect: (type: string) => void;
onDisconnect: (integrationId: string) => void;
disconnectingId?: string;
};
export default function ConnectionRow({
definition,
connection,
onConnect,
onDisconnect,
disconnectingId,
}: ConnectionRowProps) {
const { t } = useTranslation();
const connectedLabel =
connection?.providerDisplayName || connection?.providerUserId;
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
{getIntegrationIcon(definition.type, 28)}
<div>
<Text size="sm" fw={500}>
{definition.name}
</Text>
<Text size="xs" c="dimmed">
{definition.description}
</Text>
</div>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{connection ? (
<>
{connection.invalidatedAt ? (
<>
<Text size="xs" c="orange">
{t("Connection expired")}
</Text>
<Button
size="xs"
variant="light"
color="orange"
onClick={() => onConnect(definition.type)}
>
{t("Reconnect")}
</Button>
</>
) : (
<Text size="xs" c="green">
{connectedLabel
? t("Connected as {{label}}", { label: connectedLabel })
: t("Connected")}
</Text>
)}
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onDisconnect(connection.integrationId)}
loading={disconnectingId === connection.integrationId}
>
{t("Disconnect")}
</Button>
</>
) : (
<Button
size="xs"
variant="light"
onClick={() => onConnect(definition.type)}
>
{t("Connect")}
</Button>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,30 @@
import { ReactNode } from "react";
import {
FigmaIcon,
GithubIcon,
GitlabIcon,
GoogleDocsIcon,
JiraIcon,
LinearIcon,
SlackIcon,
} from "@/components/icons";
import { IconPuzzle } from "@tabler/icons-react";
const integrationIconMap: Record<string, (size: number) => ReactNode> = {
github: (size) => <GithubIcon size={size} />,
gitlab: (size) => <GitlabIcon size={size} />,
slack: (size) => <SlackIcon size={size} />,
linear: (size) => <LinearIcon size={size} />,
jira: (size) => <JiraIcon size={size} />,
figma: (size) => <FigmaIcon size={size} />,
google_docs: (size) => <GoogleDocsIcon size={size} />,
};
export function getIntegrationIcon(
type: string,
size: number,
): ReactNode {
const renderIcon = integrationIconMap[type];
if (renderIcon) return renderIcon(size);
return <IconPuzzle size={size} stroke={1.5} />;
}
@@ -0,0 +1,66 @@
import { Box, Group, Skeleton, Stack } from "@mantine/core";
const TITLE_WIDTHS = [64, 52, 60, 44, 58, 96, 56];
const DESCRIPTION_WIDTHS = [300, 250, 320, 180, 290, 270, 260];
type IntegrationListSkeletonProps = {
rows?: number;
withBadges?: boolean;
};
export default function IntegrationListSkeleton({
rows = 7,
withBadges = true,
}: IntegrationListSkeletonProps) {
return (
<Stack gap={0} aria-hidden="true">
{Array.from({ length: rows }, (_, index) => (
<Box
key={index}
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<Skeleton height={28} circle style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" h={20}>
<Skeleton
height={12}
width={TITLE_WIDTHS[index % TITLE_WIDTHS.length]}
radius="xs"
/>
{withBadges && (
<>
<Skeleton height={16} width={52} radius="xl" />
<Skeleton height={16} width={52} radius="xl" />
</>
)}
</Group>
<Group h={17}>
<Skeleton
height={10}
width={
DESCRIPTION_WIDTHS[index % DESCRIPTION_WIDTHS.length]
}
maw="100%"
radius="xs"
/>
</Group>
</Stack>
</Group>
<Skeleton
height={30}
width={64}
radius="sm"
style={{ flexShrink: 0 }}
/>
</Group>
</Box>
))}
</Stack>
);
}
@@ -0,0 +1,93 @@
import {
Group,
Text,
Badge,
Button,
Box,
Stack,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import {
IntegrationDefinition,
Integration,
} from "../types/integration.types";
import { getIntegrationIcon } from "./integration-icons";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
type IntegrationRowProps = {
definition: IntegrationDefinition;
installation?: Integration;
onInstall: (type: string) => void;
onUninstall: (integrationId: string) => void;
};
export default function IntegrationRow({
definition,
installation,
onInstall,
onUninstall,
}: IntegrationRowProps) {
const { t } = useTranslation();
const isInstalled = !!installation;
const hasAccess = useHasFeature(Feature.INTEGRATIONS);
const locked = !!definition.requiresLicense && !hasAccess;
const upgradeLabel = useUpgradeLabel();
return (
<Box
py="sm"
px="xs"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{getIntegrationIcon(definition.type, 28)}
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{definition.name}
</Text>
{locked && (
<Badge size="xs" variant="light" color="violet">
{t("Paid")}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{definition.description}
</Text>
</Stack>
</Group>
<Group gap="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
{isInstalled ? (
<Button
size="xs"
variant="subtle"
color="red"
onClick={() => onUninstall(installation.id)}
>
{t("Uninstall")}
</Button>
) : (
<Tooltip label={upgradeLabel} disabled={!locked}>
<Button
size="xs"
variant="light"
disabled={locked}
onClick={() => onInstall(definition.type)}
>
{t("Install")}
</Button>
</Tooltip>
)}
</Group>
</Group>
</Box>
);
}
@@ -0,0 +1,134 @@
import { Text, Alert, Stack } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import ConnectionRow from "../components/connection-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useMyConnections,
useDisconnectIntegration,
} from "../queries/integration-query";
import * as integrationService from "../services/integration-service";
export default function Connections() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const { data: myConnections, isLoading: loadingConnections } =
useMyConnections();
const disconnectMutation = useDisconnectIntegration();
const isLoading = loadingAvailable || loadingInstalled || loadingConnections;
const handleConnect = async (type: string) => {
const integration = installed?.find((i) => i.type === type);
if (!integration) return;
try {
// Workspace-scoped providers default the OAuth return to the admin
// integrations page; members connecting here must come back here.
const result = await integrationService.getOAuthAuthorizeUrl({
integrationId: integration.id,
returnPath: "/settings/account/connections",
});
window.location.href = result.authorizationUrl;
} catch (error) {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to start OAuth connection"),
color: "red",
});
}
};
const handleDisconnect = (integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Disconnect {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disconnects your {{name}} account. Links are not enriched for you until you reconnect.",
{ name },
)}
</Text>
),
labels: { confirm: t("Disconnect"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => disconnectMutation.mutate({ integrationId }),
});
};
// Only the row being disconnected shows a loader; isPending alone is shared by every row.
const disconnectingId = disconnectMutation.isPending
? disconnectMutation.variables?.integrationId
: undefined;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Connections")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Connections")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage the apps you have connected to your account.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton rows={3} withBadges={false} />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available
.filter((def) => {
if (!def.capabilities.includes("oauth")) return false;
return installed?.some((i) => i.type === def.type);
})
.map((def) => {
const connection = myConnections?.find(
(c) => c.type === def.type,
);
return (
<ConnectionRow
key={def.type}
definition={def}
connection={connection}
onConnect={handleConnect}
onDisconnect={handleDisconnect}
disconnectingId={disconnectingId}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,126 @@
import { Text, Alert, Stack } from "@mantine/core";
import { modals } from "@mantine/modals";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
import { getAppName } from "@/lib/config";
import SettingsTitle from "@/components/settings/settings-title";
import IntegrationRow from "../components/integration-row";
import IntegrationListSkeleton from "../components/integration-list-skeleton";
import {
useAvailableIntegrations,
useInstalledIntegrations,
useInstallIntegration,
useUninstallIntegration,
} from "../queries/integration-query";
import { getOAuthInstallUrl } from "../services/integration-service";
import { notifications } from "@mantine/notifications";
export default function Integrations() {
const { t } = useTranslation();
const { data: available, isLoading: loadingAvailable } =
useAvailableIntegrations();
const { data: installed, isLoading: loadingInstalled } =
useInstalledIntegrations();
const installMutation = useInstallIntegration();
const uninstallMutation = useUninstallIntegration();
const handleInstall = useCallback(
async (type: string) => {
const definition = available?.find((d) => d.type === type);
// OAuth providers only become installed once the admin's OAuth
// callback succeeds; a cancelled or failed flow persists nothing.
if (definition?.capabilities?.includes("oauth")) {
try {
const { authorizationUrl } = await getOAuthInstallUrl({ type });
window.location.href = authorizationUrl;
} catch (err: any) {
notifications.show({
message:
err?.response?.data?.message ?? t("Failed to start installation"),
color: "red",
});
}
return;
}
installMutation.mutate({ type });
},
[installMutation, available, t],
);
const handleUninstall = useCallback(
(integrationId: string) => {
const installation = installed?.find((i) => i.id === integrationId);
const name =
available?.find((d) => d.type === installation?.type)?.name ??
installation?.type ??
"";
modals.openConfirmModal({
title: t("Uninstall {{name}}", { name }),
centered: true,
children: (
<Text size="sm">
{t(
"This disables the {{name}} integration for the entire workspace. Members' connections are removed and links are not enriched.",
{ name },
)}
</Text>
),
labels: { confirm: t("Uninstall"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => uninstallMutation.mutate({ integrationId }),
});
},
[uninstallMutation, installed, available, t],
);
const isLoading = loadingAvailable || loadingInstalled;
const error = new URLSearchParams(window.location.search).get("error");
return (
<>
<Helmet>
<title>
{t("Integrations")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("Integrations")} />
<Text size="sm" c="dimmed" mb="md">
{t("Manage workspace integrations.")}
</Text>
{error === "oauth_failed" && (
<Alert color="red" mb="md">
{t("OAuth connection failed. Please try again.")}
</Alert>
)}
{isLoading ? (
<IntegrationListSkeleton />
) : !available?.length ? (
<Text c="dimmed" size="sm">
{t("No integrations available.")}
</Text>
) : (
<Stack gap={0}>
{available.map((def) => {
const installation = installed?.find((i) => i.type === def.type);
return (
<IntegrationRow
key={def.type}
definition={def}
installation={installation}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</Stack>
)}
</>
);
}
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
import {
decodeSlackLinkState,
confirmSlackLink,
SlackLinkStateInfo,
} from "../services/slack-link-service";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import APP_ROUTE from "@/lib/app-route";
export default function SlackLinkPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const state = searchParams.get("state");
const currentUser = useAtomValue(currentUserAtom);
const [info, setInfo] = useState<SlackLinkStateInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
if (!currentUser) {
const redirectPath = window.location.pathname + window.location.search;
navigate(`${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(redirectPath)}`);
return;
}
if (!state) {
setError(t("Missing state parameter"));
return;
}
decodeSlackLinkState(state)
.then(setInfo)
.catch((e) => setError(e?.response?.data?.message ?? e.message));
}, [state, t, currentUser, navigate]);
async function onConfirm() {
if (!state) return;
setSubmitting(true);
try {
await confirmSlackLink(state);
setDone(true);
} catch (e: any) {
setError(e?.response?.data?.message ?? e.message);
} finally {
setSubmitting(false);
}
}
if (done) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Connected")}</Text>
<Text c="dimmed">{t("You can close this tab and return to Slack.")}</Text>
</Stack>
</Card>
);
}
if (error) {
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Alert color="red" title={t("Could not link account")}>
{error}
</Alert>
</Card>
);
}
if (!info || !currentUser) {
return (
<div style={{ display: "flex", justifyContent: "center", marginTop: 80 }}>
<Loader />
</div>
);
}
return (
<Card maw={500} mx="auto" mt={80} p="lg">
<Stack>
<Text fw={600}>{t("Link your Docmost account to Slack")}</Text>
<Text>
{t("Connect Docmost account")}{" "}
<b>{currentUser.user.email}</b>{" "}
{t("to Slack user")} <b>@{info.slackUserName}</b>
{info.slackTeamName && (
<>
{" "}
{t("in")} <b>{info.slackTeamName}</b>
</>
)}
?
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => window.close()}>
{t("Cancel")}
</Button>
<Button onClick={onConfirm} loading={submitting}>
{t("Confirm")}
</Button>
</Group>
</Stack>
</Card>
);
}
@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import * as integrationService from "../services/integration-service";
export function useAvailableIntegrations() {
return useQuery({
queryKey: ["available-integrations"],
queryFn: integrationService.getAvailableIntegrations,
});
}
export function useInstalledIntegrations() {
return useQuery({
queryKey: ["installed-integrations"],
queryFn: integrationService.getInstalledIntegrations,
});
}
export function useInstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.installIntegration,
onSuccess: () => {
notifications.show({ message: t("Integration installed successfully") });
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to install integration"),
color: "red",
});
},
});
}
export function useUninstallIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.uninstallIntegration,
onSuccess: () => {
notifications.show({
message: t("Integration uninstalled successfully"),
});
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to uninstall integration"),
color: "red",
});
},
});
}
export function useMyConnections() {
return useQuery({
queryKey: ["my-connections"],
queryFn: integrationService.getMyConnections,
});
}
export function useConnectionStatus(integrationId: string | undefined) {
return useQuery({
queryKey: ["integration-connection", integrationId],
queryFn: () =>
integrationService.getConnectionStatus({
integrationId: integrationId!,
}),
enabled: !!integrationId,
});
}
export function useDisconnectIntegration() {
const qc = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: integrationService.disconnectIntegration,
onSuccess: (_data, variables) => {
notifications.show({ message: t("Integration disconnected") });
qc.invalidateQueries({
queryKey: ["integration-connection", variables.integrationId],
});
qc.invalidateQueries({ queryKey: ["my-connections"] });
// removeQueries, not invalidate: refetchOnMount false leaves invalidated inactive queries unrefreshed
qc.removeQueries({ queryKey: ["unfurl"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to disconnect integration"),
color: "red",
});
},
});
}
@@ -0,0 +1,92 @@
import api from "@/lib/api-client";
import {
IntegrationDefinition,
Integration,
ConnectionStatus,
UserConnection,
UnfurlResult,
UnfurlNeedsConnection,
} from "../types/integration.types";
export async function getAvailableIntegrations(): Promise<
IntegrationDefinition[]
> {
const req = await api.post<IntegrationDefinition[]>(
"/integrations/available",
);
return req.data;
}
export async function getInstalledIntegrations(): Promise<Integration[]> {
const req = await api.post<Integration[]>("/integrations/list");
return req.data;
}
export async function installIntegration(data: {
type: string;
}): Promise<Integration> {
const req = await api.post<Integration>("/integrations/install", data);
return req.data;
}
export async function uninstallIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/uninstall", data);
}
export async function getMyConnections(): Promise<UserConnection[]> {
const req = await api.post<UserConnection[]>("/integrations/connections/mine");
return req.data;
}
export async function getConnectionStatus(data: {
integrationId: string;
}): Promise<ConnectionStatus> {
const req = await api.post<ConnectionStatus>(
"/integrations/connection/status",
data,
);
return req.data;
}
export async function getOAuthAuthorizeUrl(data: {
integrationId: string;
returnPath?: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/authorize",
data,
);
return req.data;
}
/**
* For workspace-scoped providers: returns the authorize URL WITHOUT creating
* the integration row. The row is created atomically when the OAuth callback
* succeeds; a cancelled OAuth leaves no half-installed state.
*/
export async function getOAuthInstallUrl(data: {
type: string;
}): Promise<{ authorizationUrl: string }> {
const req = await api.post<{ authorizationUrl: string }>(
"/integrations/oauth/install",
data,
);
return req.data;
}
export async function disconnectIntegration(data: {
integrationId: string;
}): Promise<void> {
await api.post("/integrations/oauth/disconnect", data);
}
export async function unfurlUrl(data: {
url: string;
}): Promise<UnfurlResult | UnfurlNeedsConnection | null> {
const req = await api.post<{
data: UnfurlResult | UnfurlNeedsConnection | null;
}>("/integrations/unfurl", data);
return req.data.data;
}
@@ -0,0 +1,23 @@
import api from "@/lib/api-client";
export type SlackLinkStateInfo = {
slackUserName: string;
slackUserId: string;
slackTeamId: string;
slackTeamName: string | null;
integrationWorkspaceId: string | undefined;
};
export async function decodeSlackLinkState(
state: string,
): Promise<SlackLinkStateInfo> {
const req = await api.post<SlackLinkStateInfo>(
"/integrations/slack/link/state",
{ state },
);
return req.data;
}
export async function confirmSlackLink(state: string): Promise<void> {
await api.post("/integrations/slack/link", { state });
}
@@ -0,0 +1,69 @@
export type IntegrationCapability = "oauth" | "unfurl" | "actions";
export type OAuthConfig = {
authUrl: string;
tokenUrl: string;
scopes: string[];
connectionScope?: 'workspace' | 'user';
};
export type IntegrationDefinition = {
type: string;
name: string;
description: string;
icon: string;
capabilities: IntegrationCapability[];
oauth?: OAuthConfig;
requiresLicense?: boolean;
};
export type Integration = {
id: string;
workspaceId: string;
type: string;
settings: Record<string, any> | null;
installedById: string | null;
createdAt: string;
updatedAt: string;
};
export type ConnectionStatus = {
connected: boolean;
providerUserId?: string;
};
export type UserConnection = {
integrationId: string;
type: string;
providerUserId: string | null;
providerDisplayName: string | null;
connectedAt: string;
invalidatedAt: string | null;
};
export type UnfurlResult = {
title: string;
description?: string;
url: string;
provider: string;
providerIcon?: string;
status?: string;
statusColor?: string;
author?: string;
authorAvatarUrl?: string;
metadata?: Record<string, any>;
};
// Returned when the link's provider needs a per-user connection the
// requesting user has not authorized yet.
export type UnfurlNeedsConnection = {
needsConnection: true;
integrationId: string;
integrationType: string;
integrationName: string;
// false for workspace-scoped providers (Slack): linking happens on the
// provider's side, so no Docmost-initiated OAuth button.
oauthConnect: boolean;
title: string;
description?: string;
};
@@ -63,12 +63,6 @@ export function NotificationItem({
return "Page verification expires soon";
case "page.verification_expired":
return "Page verification has expired";
case "siem_destination.failing":
return "SIEM destination <bold>{{name}}</bold> is failing";
case "siem_destination.disabled":
return "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures";
case "siem_destination.recovered":
return "SIEM destination <bold>{{name}}</bold> recovered";
default:
return "";
}
@@ -83,19 +77,6 @@ export function NotificationItem({
)
: undefined;
const isSiemDestination = notification.type.startsWith("siem_destination.");
const destinationName =
typeof notification.data?.destinationName === "string"
? notification.data.destinationName
: "";
const lastError =
(notification.type === "siem_destination.failing" ||
notification.type === "siem_destination.disabled") &&
typeof notification.data?.lastError === "string"
? notification.data.lastError
: null;
const linkUrl = isSiemDestination ? "/settings/audit/siem" : pageUrl;
const markReadIfNeeded = () => {
if (isUnread) {
markRead.mutate([notification.id]);
@@ -116,7 +97,7 @@ export function NotificationItem({
return (
<UnstyledButton
component={Link}
to={linkUrl ?? ""}
to={pageUrl ?? ""}
onClick={handleClick}
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
// so that right-click (button 2, context menu) does not mark as read
@@ -143,21 +124,11 @@ export function NotificationItem({
<Text size="sm" lineClamp={2}>
<Trans
i18nKey={getNotificationMessageKey()}
values={{
name: isSiemDestination
? destinationName
: notification.actor?.name,
}}
values={{ name: notification.actor?.name }}
components={{ bold: <Text span fw={600} /> }}
/>
</Text>
{lastError && (
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
{lastError}
</Text>
)}
{notification.page && (
<Group gap={4} mt={2} wrap="nowrap">
{notification.page.icon ? (
@@ -9,10 +9,7 @@ export type NotificationType =
| "page.verification_expired"
| "page.verified"
| "page.approval_requested"
| "page.approval_rejected"
| "siem_destination.failing"
| "siem_destination.disabled"
| "siem_destination.recovered";
| "page.approval_rejected";
export type INotification = {
id: string;
@@ -1,63 +1,16 @@
import classes from "./page-header.module.css";
import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx";
import { Badge, Group, Tooltip } from "@mantine/core";
import { IconExternalLink, IconWorld } from "@tabler/icons-react";
import { Group } from "@mantine/core";
import Breadcrumb from "@/features/page/components/breadcrumbs/breadcrumb.tsx";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
import { isBetaPublicSpaces } from "@/lib/config.ts";
interface Props {
readOnly?: boolean;
}
export default function PageHeader({ readOnly }: Props) {
const { t } = useTranslation();
const { spaceSlug, pageSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
const { data: page } = usePageQuery({
pageId: extractPageSlugId(pageSlug),
});
// Restricted pages are never publicly reachable, so the chip only shows on
// pages the public site actually serves.
const showPublicBadge =
isBetaPublicSpaces() &&
space?.isPublished &&
page &&
page.permissions?.hasRestriction !== true;
return (
<div className={classes.header} data-page-header="true">
<Group justify="space-between" h="100%" px="md" wrap="nowrap" className={classes.group}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Breadcrumb />
{showPublicBadge && (
<Tooltip label={t("Open public page")} openDelay={250} withArrow>
<Badge
component="a"
href={buildPublicSpaceUrl({
spaceSlug: space.slug,
pageSlugId: page.slugId,
pageTitle: page.title,
})}
target="_blank"
rel="noopener"
size="sm"
variant="light"
leftSection={<IconWorld size={12} />}
rightSection={<IconExternalLink size={11} />}
style={{ flexShrink: 0, cursor: "pointer" }}
>
{t("Public")}
</Badge>
</Tooltip>
)}
</Group>
<Breadcrumb />
<Group justify="flex-end" h="100%" px="md" wrap="nowrap" gap="var(--mantine-spacing-xs)">
<PageHeaderMenu readOnly={readOnly} />
+1 -48
View File
@@ -25,37 +25,11 @@ const buildPageSlug = (pageSlugId: string, pageTitle?: string): string => {
return `${titleSlug}-${pageSlugId}`;
};
function appendSearchParams(
url: string,
search?: string[],
wholeWord?: boolean,
): string {
if(search?.length === 0){
return url;
}
const params = new URLSearchParams();
search
?.map((term) => term.trim())
.filter(Boolean)
.forEach((term) => params.append("q", term));
if (wholeWord) {
params.set("m", "whole");
}
const queryString = params.toString();
return queryString ? `${url}?${queryString}` : url;
}
export const buildPageUrl = (
spaceName: string,
pageSlugId: string,
pageTitle?: string,
anchorId?: string,
search?: string[],
wholeWord?: boolean,
): string => {
let url: string;
if (spaceName === undefined) {
@@ -63,9 +37,6 @@ export const buildPageUrl = (
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -74,31 +45,13 @@ export const buildSharedPageUrl = (opts: {
pageSlugId: string;
pageTitle?: string;
anchorId?: string;
search?: string[];
wholeWord?: boolean;
}): string => {
const { shareId, pageSlugId, pageTitle, anchorId, search, wholeWord } = opts;
const { shareId, pageSlugId, pageTitle, anchorId } = opts;
let url: string;
if (!shareId) {
url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}
url = appendSearchParams(url, search, wholeWord);
return anchorId ? `${url}#${anchorId}` : url;
};
export const buildPublicSpaceUrl = (opts: {
spaceSlug: string;
pageSlugId?: string;
pageTitle?: string;
anchorId?: string;
}): string => {
const { spaceSlug, pageSlugId, pageTitle, anchorId } = opts;
const url = pageSlugId
? `/docs/${spaceSlug}/${buildPageSlug(pageSlugId, pageTitle)}`
: `/docs/${spaceSlug}`;
return anchorId ? `${url}#${anchorId}` : url;
};
@@ -41,7 +41,6 @@ type Props<T extends object> = {
activeId?: string;
renderRow: (props: RenderRowProps<T>) => ReactNode;
indentPerLevel: number;
rowClassName?: string;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
onToggle: (id: string, isOpen: boolean) => void;
readOnly: boolean;
@@ -69,7 +68,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
activeId,
renderRow,
indentPerLevel,
rowClassName,
onMove,
onToggle,
readOnly,
@@ -325,7 +323,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
style={{ paddingLeft: level * indentPerLevel }}
>
<div
className={rowClassName ? `${styles.node} ${rowClassName}` : styles.node}
className={styles.node}
data-dragging={isDragging || undefined}
data-selected={isSelected || undefined}
data-receiving-drop={
@@ -378,7 +376,6 @@ function arePropsEqual<T extends object>(
if (prev.readOnly !== next.readOnly) return false;
if (prev.contextId !== next.contextId) return false;
if (prev.indentPerLevel !== next.indentPerLevel) return false;
if (prev.rowClassName !== next.rowClassName) return false;
if (prev.renderRow !== next.renderRow) return false;
if (prev.onMove !== next.onMove) return false;
if (prev.onToggle !== next.onToggle) return false;
@@ -56,18 +56,6 @@ export type DocTreeProps<T extends object> = {
indentPerLevel?: number;
rowHeight?: number;
emptyState?: ReactNode;
// Extra class for the engine's row wrapper (the div carrying data-selected /
// data-dragging), letting consumers restyle hover/selected states.
rowClassName?: string;
// Extra vertical space above a row (e.g. to separate top-level groups).
// Added to the row's virtualized slot and applied as padding above it.
rowGap?: (node: TreeNode<T>, level: number, index: number) => number;
// Measure real row heights so rows may wrap to multiple lines; rowHeight
// then only seeds the estimate. Off by default (fixed-slot fast path).
dynamicRowHeight?: boolean;
// Rendered inside the scroll container after the last row, so it flows
// with the tree content instead of pinning to the container edge.
footer?: ReactNode;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
onToggle: (id: string, isOpen: boolean) => void;
@@ -135,9 +123,6 @@ function DocTreeInner<T extends object>(
renderRow,
indentPerLevel = 16,
rowHeight = 32,
rowClassName,
rowGap,
dynamicRowHeight = false,
onMove,
onToggle,
onSelect,
@@ -147,7 +132,6 @@ function DocTreeInner<T extends object>(
getDragLabel,
uniqueContextId,
emptyState,
footer,
'aria-label': ariaLabel,
} = props;
@@ -213,20 +197,10 @@ function DocTreeInner<T extends object>(
return flat[0]?.node.id;
}, [activeId, selectedId, flatIds, flat]);
// Keyed by node id so measured sizes follow their rows across
// expand/collapse instead of sticking to positions. Never reset the
// measurement cache wholesale: ResizeObserver only re-reports elements
// whose size changed, so unchanged mounted rows would be left positioned
// by the estimate and overlap their neighbors.
const virtualizer = useVirtualizer({
count: flat.length,
getScrollElement: () => scrollRef.current,
getItemKey: (index) => flat[index].node.id,
estimateSize: (index) => {
const row = flat[index];
const gap = row && rowGap ? rowGap(row.node, row.level, index) : 0;
return rowHeight + gap;
},
estimateSize: () => rowHeight,
overscan: 10,
});
@@ -497,12 +471,7 @@ function DocTreeInner<T extends object>(
);
if (data.length === 0 && emptyState) {
return (
<div className={styles.treeContainer}>
{emptyState}
{footer}
</div>
);
return <div className={styles.treeContainer}>{emptyState}</div>;
}
const virtualItems = virtualizer.getVirtualItems();
@@ -525,9 +494,6 @@ function DocTreeInner<T extends object>(
>
{virtualItems.map((virtualItem) => {
const row = flat[virtualItem.index];
const gap = rowGap
? rowGap(row.node, row.level, virtualItem.index)
: 0;
return (
<li
key={row.node.id}
@@ -535,15 +501,12 @@ function DocTreeInner<T extends object>(
// (the row's <a>), so screen readers announce "treeitem" on
// navigation. The <li> is just layout glue.
role="none"
ref={dynamicRowHeight ? virtualizer.measureElement : undefined}
data-index={dynamicRowHeight ? virtualItem.index : undefined}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
...(gap > 0 ? { paddingTop: gap } : {}),
}}
>
<DocTreeRow
@@ -555,7 +518,6 @@ function DocTreeInner<T extends object>(
activeId={effectiveActiveId}
renderRow={renderRow}
indentPerLevel={indentPerLevel}
rowClassName={rowClassName}
onMove={onMove}
onToggle={onToggle}
readOnly={readOnly}
@@ -570,7 +532,6 @@ function DocTreeInner<T extends object>(
);
})}
</ul>
{footer}
</div>
);
}
@@ -1,17 +0,0 @@
import { atom } from "jotai";
import { IPublicSpaceTree } from "@/features/public-space/types/public-space.types.ts";
import { SharedPageTreeNode } from "@/features/share/utils.ts";
export const publicSpaceTreeAtom = atom<IPublicSpaceTree | null>(
null as IPublicSpaceTree | null,
);
export const publicSpaceTreeDataAtom = atom<SharedPageTreeNode[] | null>(
null as SharedPageTreeNode[] | null,
);
export const openPublicSpaceTreeNodesAtom = atom<Record<string, boolean>>({});
export const docsMobileSidebarAtom = atom<boolean>(false);
export const docsMobileTocAtom = atom<boolean>(false);
@@ -1,51 +0,0 @@
.presetRow {
display: flex;
flex-wrap: wrap;
gap: rem(8px);
}
.presetCard {
display: flex;
flex-direction: column;
align-items: center;
gap: rem(6px);
min-width: rem(72px);
padding: rem(10px) rem(12px);
border: 1px solid var(--mantine-color-default-border);
border-radius: rem(8px);
@media (hover: hover) {
&:hover {
background-color: var(--mantine-color-default-hover);
}
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 1px;
}
}
.presetCard[data-selected="true"] {
border-color: var(--mantine-primary-color-filled);
background-color: var(--mantine-primary-color-light);
}
.presetSwatch {
width: rem(22px);
height: rem(22px);
border-radius: 50%;
border: 1px solid var(--mantine-color-default-border);
flex-shrink: 0;
}
.customSwatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: rem(22px);
height: rem(22px);
border-radius: 50%;
border: 1px dashed var(--mantine-color-default-border);
color: var(--mantine-color-dimmed);
}
@@ -1,165 +0,0 @@
import {
ColorInput,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconColorPicker } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
DEFAULT_DOCS_PRESET,
DOCS_THEME_PRESETS,
isValidDocsColor,
matchDocsPreset,
} from "@/features/public-space/theme/docs-theme.ts";
import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts";
import { usePublishSpaceMutation } from "@/features/public-space/queries/public-space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
import { Feature } from "@/ee/features.ts";
import classes from "./appearance-settings.module.css";
type AppearanceSettingsProps = {
spaceId: string;
appearance?: IPublicSpaceAppearance;
};
export default function AppearanceSettings({
spaceId,
appearance,
}: AppearanceSettingsProps) {
const { t } = useTranslation();
const publishMutation = usePublishSpaceMutation();
const hasAppearance = useHasFeature(Feature.PUBLIC_SPACE_APPEARANCE);
const upgradeLabel = useUpgradeLabel();
const matchedPreset = matchDocsPreset(appearance);
const [customOpen, setCustomOpen] = useState(matchedPreset === null);
const [customLight, setCustomLight] = useState(
appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light,
);
const [customDark, setCustomDark] = useState(
appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark,
);
useEffect(() => {
setCustomOpen(matchDocsPreset(appearance) === null);
setCustomLight(appearance?.primaryColorLight ?? DEFAULT_DOCS_PRESET.light);
setCustomDark(appearance?.primaryColorDark ?? DEFAULT_DOCS_PRESET.dark);
}, [appearance?.primaryColorLight, appearance?.primaryColorDark]);
const saveAppearance = (payload: {
primaryColorLight: string | null;
primaryColorDark: string | null;
}) => {
if (!hasAppearance) return;
publishMutation.mutate({ spaceId, enabled: true, appearance: payload });
};
const selectPreset = (presetId: string) => {
setCustomOpen(false);
const preset = DOCS_THEME_PRESETS.find((item) => item.id === presetId);
if (!preset) return;
if (preset.id === DEFAULT_DOCS_PRESET.id) {
saveAppearance({ primaryColorLight: null, primaryColorDark: null });
return;
}
saveAppearance({
primaryColorLight: preset.light,
primaryColorDark: preset.dark,
});
};
const commitCustom = (light: string, dark: string) => {
if (!isValidDocsColor(light) || !isValidDocsColor(dark)) return;
saveAppearance({ primaryColorLight: light, primaryColorDark: dark });
};
const swatches = DOCS_THEME_PRESETS.flatMap((preset) => [
preset.light,
preset.dark,
]);
return (
<div>
<Text size="md" mt="md">
{t("Appearance")}
</Text>
<Text size="sm" c="dimmed">
{t("Choose the primary color of the public docs site.")}
</Text>
<Tooltip
label={upgradeLabel}
disabled={hasAppearance}
position="top-start"
>
<div className={classes.presetRow} style={{ marginTop: 10 }}>
{DOCS_THEME_PRESETS.map((preset) => {
const selected = !customOpen && matchedPreset?.id === preset.id;
return (
<UnstyledButton
key={preset.id}
className={classes.presetCard}
data-selected={selected || undefined}
aria-pressed={selected}
disabled={!hasAppearance}
onClick={() => selectPreset(preset.id)}
>
<span
className={classes.presetSwatch}
style={{
background: `linear-gradient(135deg, ${preset.light} 50%, ${preset.dark} 50%)`,
}}
/>
<Text size="xs">{t(preset.nameKey)}</Text>
</UnstyledButton>
);
})}
<UnstyledButton
className={classes.presetCard}
data-selected={customOpen || undefined}
aria-pressed={customOpen}
disabled={!hasAppearance}
onClick={() => setCustomOpen(true)}
>
<span className={classes.customSwatch}>
<IconColorPicker size={13} stroke={2} aria-hidden />
</span>
<Text size="xs">{t("Custom")}</Text>
</UnstyledButton>
</div>
</Tooltip>
{customOpen && hasAppearance && (
<Group grow mt="sm" align="flex-start">
<ColorInput
label={t("Light mode color")}
format="hex"
value={customLight}
swatches={swatches}
onChange={setCustomLight}
onChangeEnd={(value) => {
setCustomLight(value);
commitCustom(value, customDark);
}}
/>
<ColorInput
label={t("Dark mode color")}
format="hex"
value={customDark}
swatches={swatches}
onChange={setCustomDark}
onChangeEnd={(value) => {
setCustomDark(value);
commitCustom(customLight, value);
}}
/>
</Group>
)}
</div>
);
}
@@ -1,114 +0,0 @@
import { Menu } from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Fragment, useMemo, type ReactNode } from "react";
import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts";
import { extractPageSlugId } from "@/lib";
import styles from "./docs.module.css";
type Crumb = {
key: string;
name: string;
url: string;
};
export default function DocsBreadcrumbs() {
const { t } = useTranslation();
const { pageSlug } = useParams();
const { treeData, siteName, homeUrl, getNodeUrl } = useDocsSurface();
const isMobile = useMediaQuery("(max-width: 48em)");
const crumbs = useMemo<Crumb[] | null>(() => {
if (!treeData?.length) return null;
const currentSlugId = pageSlug
? extractPageSlugId(pageSlug)
: treeData[0]?.slugId;
if (!currentSlugId) return null;
const trail = findAncestorTrail(treeData, currentSlugId);
if (trail === null) return null;
const siteCrumbs: Crumb[] =
siteName && homeUrl
? [{ key: "site", name: siteName, url: homeUrl }]
: [];
const list = [
...siteCrumbs,
...trail.map((node) => ({
key: node.slugId,
name: node.name || t("untitled"),
url: getNodeUrl(node),
})),
];
return list.length ? list : null;
}, [treeData, siteName, homeUrl, getNodeUrl, pageSlug, t]);
if (!crumbs) return null;
// Mobile keeps a single line (menu + last crumb, like the app header's
// breadcrumb); desktop collapses the middle beyond 4 crumbs.
const collapsed = crumbs.length > (isMobile ? 1 : 4);
const hidden = !collapsed
? []
: isMobile
? crumbs.slice(0, crumbs.length - 1)
: crumbs.slice(1, crumbs.length - 1);
const items: ReactNode[] = [];
if (collapsed && !isMobile) {
items.push(
<Link key={crumbs[0].key} to={crumbs[0].url} className={styles.crumbLink}>
{crumbs[0].name}
</Link>,
);
}
if (collapsed) {
items.push(
<Menu shadow="md" position="bottom-start" key="hidden">
<Menu.Target>
<button
type="button"
className={styles.crumbEllipsis}
aria-label={t("Show hidden pages")}
>
</button>
</Menu.Target>
<Menu.Dropdown>
{hidden.map((item) => (
<Menu.Item key={item.key} component={Link} to={item.url}>
{item.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>,
);
}
const trailing = collapsed ? [crumbs[crumbs.length - 1]] : crumbs;
for (const crumb of trailing) {
items.push(
<Link key={crumb.key} to={crumb.url} className={styles.crumbLink}>
{crumb.name}
</Link>,
);
}
return (
<nav className={styles.breadcrumbs} aria-label={t("Breadcrumb")}>
{items.map((item, index) => (
<Fragment key={index}>
{index > 0 && (
<span className={styles.crumbSeparator} aria-hidden>
/
</span>
)}
{item}
</Fragment>
))}
</nav>
);
}
@@ -1,45 +0,0 @@
import { Button } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import styles from "./docs.module.css";
export default function DocsCopyPage() {
const { t } = useTranslation();
const editor = useAtomValue(readOnlyEditorAtom);
const page = useDocsCurrentPage();
const clipboard = useClipboard();
if (!editor) {
return null;
}
const handleCopy = () => {
if (editor.isDestroyed) return;
const markdown = htmlToMarkdown(editor.getHTML());
const title = page?.name ? `# ${page.name}\n\n` : "";
clipboard.copy(`${title}${markdown}`);
};
return (
<Button
variant="default"
size="compact-sm"
className={styles.copyPageButton}
onClick={handleCopy}
leftSection={
clipboard.copied ? (
<IconCheck size={14} stroke={1.8} />
) : (
<IconCopy size={14} stroke={1.8} />
)
}
>
{clipboard.copied ? t("Copied") : t("Copy page")}
</Button>
);
}

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