- {t("Require OAuth")}
+ {t("Enforce OAuth")}
{!hasAccess && (
@@ -202,7 +202,7 @@ function McpOauthOnlySetting() {
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
- aria-label={t("Toggle require OAuth for MCP")}
+ aria-label={t("Toggle enforce OAuth for MCP")}
/>
diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts
index 1722370ef..80bf1f69d 100644
--- a/apps/client/src/features/workspace/types/workspace.types.ts
+++ b/apps/client/src/features/workspace/types/workspace.types.ts
@@ -27,7 +27,7 @@ export interface IWorkspace {
mcpEnabled?: boolean;
aiChatReadOnly?: boolean;
aiChatWorkspaceKnowledgeOnly?: boolean;
- mcpOauthOnly?: boolean;
+ enforceMcpOauth?: boolean;
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
@@ -53,7 +53,7 @@ export interface IWorkspaceAiSettings {
search?: boolean;
generative?: boolean;
mcp?: boolean;
- mcpOauthOnly?: boolean;
+ enforceMcpOauth?: boolean;
chat?: boolean;
chatReadOnly?: boolean;
chatWorkspaceKnowledgeOnly?: boolean;
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index 55b19283d..51b749516 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -79,5 +79,5 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
- mcpOauthOnly: boolean;
+ enforceMcpOauth: boolean;
}
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index efb10a647..1f5db0fec 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -337,7 +337,7 @@ export class WorkspaceService {
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' ||
- typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined'
+ typeof updateWorkspaceDto.enforceMcpOauth !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -392,7 +392,7 @@ export class WorkspaceService {
}
}
- if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
+ if (typeof updateWorkspaceDto.enforceMcpOauth !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
@@ -574,16 +574,16 @@ export class WorkspaceService {
);
}
- if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
- const prev = settingsBefore?.ai?.mcpOauthOnly ?? false;
- if (prev !== updateWorkspaceDto.mcpOauthOnly) {
- before.mcpOauthOnly = prev;
- after.mcpOauthOnly = updateWorkspaceDto.mcpOauthOnly;
+ if (typeof updateWorkspaceDto.enforceMcpOauth !== 'undefined') {
+ const prev = settingsBefore?.ai?.enforceMcpOauth ?? false;
+ if (prev !== updateWorkspaceDto.enforceMcpOauth) {
+ before.enforceMcpOauth = prev;
+ after.enforceMcpOauth = updateWorkspaceDto.enforceMcpOauth;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
- 'mcpOauthOnly',
- updateWorkspaceDto.mcpOauthOnly,
+ 'enforceMcpOauth',
+ updateWorkspaceDto.enforceMcpOauth,
trx,
);
}
@@ -627,7 +627,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.defaultPageEditMode;
delete updateWorkspaceDto.aiChatReadOnly;
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
- delete updateWorkspaceDto.mcpOauthOnly;
+ delete updateWorkspaceDto.enforceMcpOauth;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
diff --git a/apps/server/src/ee b/apps/server/src/ee
index ae1bfe5b5..844f2003c 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit ae1bfe5b5c3672e3d910af8b3b6beb85e4b5e057
+Subproject commit 844f2003cdc36268478fdfb5ad64b656df6b633b
From c3d9a19545a1b3899db021296d01424f61a3a0c1 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Wed, 26 Aug 2026 14:08:10 +0100
Subject: [PATCH 18/27] New Crowdin updates (#2332)
---
.../public/locales/de-DE/translation.json | 54 +++-
.../public/locales/en-US/translation.json | 38 +--
.../public/locales/es-ES/translation.json | 64 ++++-
.../public/locales/fr-FR/translation.json | 64 ++++-
.../public/locales/it-IT/translation.json | 66 ++++-
.../public/locales/ja-JP/translation.json | 54 +++-
.../public/locales/ko-KR/translation.json | 232 +++++++++++-------
.../public/locales/nl-NL/translation.json | 54 +++-
.../public/locales/pt-BR/translation.json | 54 +++-
.../public/locales/ru-RU/translation.json | 64 ++++-
.../public/locales/uk-UA/translation.json | 54 +++-
.../public/locales/zh-CN/translation.json | 54 +++-
12 files changed, 690 insertions(+), 162 deletions(-)
diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json
index 084c92ef5..89bafb092 100644
--- a/apps/client/public/locales/de-DE/translation.json
+++ b/apps/client/public/locales/de-DE/translation.json
@@ -294,6 +294,7 @@
"Export space": "Bereich exportieren",
"Export {{type}}": "Exportiere {{type}}",
"File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}",
+ "Media": "Medien",
"Align left": "Links ausrichten",
"Align right": "Rechts ausrichten",
"Align center": "Zentrieren",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"Page break": "Seitenumbruch",
"Insert a page break for printing.": "Einen Seitenumbruch zum Drucken einfügen.",
+ "Footnote": "Fußnote",
+ "Insert a footnote reference.": "Einen Fußnotenverweis einfügen.",
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Aktivieren Sie den MCP-Server, damit KI-Assistenten und -Tools mit den Inhalten Ihres Arbeitsbereichs interagieren können.",
"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",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Verwenden Sie Ihren API-Schlüssel zur Authentifizierung. API-Schlüssel können in Ihren Kontoeinstellungen verwaltet werden.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
+ "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": "Toggle enforce OAuth for MCP",
"Supported tools": "Unterstützte Tools",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In Ihrem Arbeitsbereich ist MCP aktiviert. Verwenden Sie Ihren API-Schlüssel, um KI-Assistenten anzubinden.",
"MCP server URL:": "MCP-Server-URL:",
"Learn more": "Mehr erfahren",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Verwalten Sie API-Schlüssel für alle Nutzer im Arbeitsbereich. Siehe die
API-Dokumentation für Details zur Verwendung.",
@@ -1289,5 +1294,48 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} selected_other": "{{count}} selected",
+ "Compare": "Vergleichen",
+ "Compare versions": "Versionen vergleichen",
+ "Select version from {{date}}": "Version vom {{date}} auswählen",
+ "Version actions for {{date}}": "Versionsaktionen für {{date}}",
+ "Comparing {{newer}} and {{older}}": "{{newer}} und {{older}} werden verglichen",
+ "Exit compare": "Vergleich beenden",
+ "Search attachments...": "Anhänge suchen...",
+ "Error loading attachments.": "Fehler beim Laden der Anhänge.",
+ "No attachments on this page yet.": "Auf dieser Seite gibt es noch keine Anhänge.",
+ "Uploaded by {{name}}": "Hochgeladen von {{name}}",
+ "Download {{name}}": "{{name}} herunterladen",
+ "Access revoked": "Zugriff widerrufen",
+ "Authorize application": "Anwendung autorisieren",
+ "{{name}} wants to access {{workspace}}": "{{name}} möchte auf {{workspace}} zugreifen",
+ "Not you? Switch account": "Nicht du? Konto wechseln",
+ "This application will be able to:": "Diese Anwendung kann Folgendes:",
+ "Write": "Schreiben",
+ "Invalid authorization request": "Ungültige Autorisierungsanfrage",
+ "Authorize": "Autorisieren",
+ "Application": "Anwendung",
+ "Permissions": "Berechtigungen",
+ "Authorized": "Autorisiert",
+ "Revoke access": "Zugriff widerrufen",
+ "Revoke access for {{name}}": "Zugriff für {{name}} widerrufen",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Möchtest du den Zugriff für {{name}} wirklich widerrufen? Die Anwendung kann dann nicht mehr auf dein Konto zugreifen.",
+ "Something went wrong. Please try again.": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
+ "Remove {{name}}": "{{name}} entfernen",
+ "Make sure you trust this application before authorizing it.": "Stelle sicher, dass du dieser Anwendung vertraust, bevor du sie autorisierst.",
+ "You will be redirected to": "Du wirst weitergeleitet zu",
+ "View content without making changes.": "Inhalte ansehen, ohne Änderungen vorzunehmen.",
+ "Create and modify content.": "Inhalte erstellen und bearbeiten.",
+ "Applications and AI assistants you have authorized to access your account.": "Anwendungen und AI-Assistenten, denen du den Zugriff auf dein Konto autorisiert hast.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP ist für deinen Workspace aktiviert. Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
+ "Authorized apps": "Autorisierte Apps",
+ "No authorized apps yet.": "Noch keine autorisierten Apps.",
+ "Workspace knowledge only": "Nur Workspace-Wissen",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Beschränke den AI-Chat darauf, nur anhand deiner Workspace-Seiten und hochgeladenen Dateien zu antworten. Es wird kein externes Wissen verwendet.",
+ "Toggle workspace knowledge only": "Nur Workspace-Wissen umschalten",
+ "Read-only mode": "Schreibgeschützter Modus",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI-Chat kann Workspace-Inhalte durchsuchen und lesen, aber keine Seiten erstellen oder bearbeiten.",
+ "Toggle AI Chat read-only mode": "Schreibgeschützten Modus für AI-Chat umschalten",
+ "Title only": "Nur Titel",
+ "you": "du"
}
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index c2457dea3..3a2736f4b 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -47,24 +47,24 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.",
"Description": "Description",
"Details": "Details",
- "e.g ACME": "e.g ACME",
- "e.g ACME Inc": "e.g ACME Inc",
- "e.g Developers": "e.g Developers",
- "e.g Group for developers": "e.g Group for developers",
- "e.g product": "e.g product",
- "e.g Product Team": "e.g Product Team",
- "e.g Sales": "e.g Sales",
- "e.g Space for product team": "e.g Space for product team",
- "e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
+ "e.g ACME": "e.g. ACME",
+ "e.g ACME Inc": "e.g. ACME Inc",
+ "e.g Developers": "e.g. Developers",
+ "e.g Group for developers": "e.g. Group for developers",
+ "e.g product": "e.g. product",
+ "e.g Product Team": "e.g. Product Team",
+ "e.g Sales": "e.g. Sales",
+ "e.g Space for product team": "e.g. Space for product team",
+ "e.g Space for sales team to collaborate": "e.g. Space for sales team to collaborate",
"Edit": "Edit",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
"Enter a strong password": "Enter a strong password",
"Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]",
- "enter valid emails addresses": "enter valid emails addresses",
+ "enter valid emails addresses": "Enter valid email addresses",
"Enter your current password": "Enter your current password",
- "enter your full name": "enter your full name",
+ "enter your full name": "Enter your full name",
"Enter your new password": "Enter your new password",
"Enter your new preferred email": "Enter your new preferred email",
"Enter your password": "Enter your password",
@@ -112,7 +112,7 @@
"Import pages": "Import pages",
"Import pages & space settings": "Import pages & space settings",
"Importing pages": "Importing pages",
- "invalid invitation link": "invalid invitation link",
+ "invalid invitation link": "Invalid invitation link",
"Invitation signup": "Invitation signup",
"Invite by email": "Invite by email",
"Invite members": "Invite members",
@@ -223,7 +223,7 @@
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle",
"Comments": "Comments",
- "404 page not found": "404 page not found",
+ "404 page not found": "404! Page not found",
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
"Take me back to homepage": "Take me back to homepage",
"Forgot password": "Forgot password",
@@ -423,7 +423,7 @@
"Insert current date": "Insert current date",
"Time": "Time",
"Insert current time": "Insert current time",
- "Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
+ "Draw and sketch excalidraw diagrams": "Draw and sketch Excalidraw diagrams",
"Multiple": "Multiple",
"Turn into": "Turn into",
"Text align": "Text align",
@@ -564,7 +564,7 @@
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.",
"Your new backup codes": "Your new backup codes",
"I've saved my backup codes": "I've saved my backup codes",
- "Failed to setup MFA": "Failed to setup MFA",
+ "Failed to setup MFA": "Failed to set up MFA",
"Setup & Verify": "Setup & Verify",
"Add to authenticator": "Add to authenticator",
"1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app",
@@ -638,7 +638,7 @@
"Enterprise": "Enterprise",
"Download attachment": "Download attachment",
"Allowed email domains": "Allowed email domains",
- "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can signup via SSO.",
+ "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.",
"Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space",
"Enforce two-factor authentication": "Enforce two-factor authentication",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Once enforced, all members must enable two-factor authentication to access the workspace.",
@@ -829,7 +829,7 @@
"Choose how this page should stay accurate.": "Choose how this page should stay accurate.",
"Recurring verification": "Recurring verification",
"Verifiers re-confirm this page on a schedule.": "Verifiers re-confirm this page on a schedule.",
- "Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g every 30 days )",
+ "Re-verify on a schedule (e.g every 30 days )": "Re-verify on a schedule (e.g. every 30 days)",
"Page stays editable at all times": "Page stays editable at all times",
"Best for runbooks, FAQs, living documentation": "Best for runbooks, FAQs, living documentation",
"Approval workflow": "Approval workflow",
@@ -905,7 +905,7 @@
"Publish": "Publish",
"Security": "Security",
"Enforce SSO": "Enforce SSO",
- "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.",
+ "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.",
"AI-generated content may not be accurate.": "AI-generated content may not be accurate.",
"AI Chat": "AI Chat",
"Analyze for insights": "Analyze for insights",
@@ -1251,7 +1251,7 @@
"Not started": "Not started",
"Number": "Number",
"One month ago": "One month ago",
- "One month from now": "One month from now",
+ "One month from now": "In one month",
"One week ago": "One week ago",
"One week from now": "One week from now",
"Open as page": "Open as page",
diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json
index fb5364725..1ee829218 100644
--- a/apps/client/public/locales/es-ES/translation.json
+++ b/apps/client/public/locales/es-ES/translation.json
@@ -294,6 +294,7 @@
"Export space": "Exportar espacio",
"Export {{type}}": "Exportar {{type}}",
"File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos",
+ "Media": "Multimedia",
"Align left": "Alinear a la izquierda",
"Align right": "Alinear a la derecha",
"Align center": "Alinear al centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insertar regla horizontal",
"Page break": "Salto de página",
"Insert a page break for printing.": "Inserta un salto de página para imprimir.",
+ "Footnote": "Nota al pie",
+ "Insert a footnote reference.": "Insertar una referencia de nota al pie.",
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Habilite el servidor MCP para permitir que asistentes de IA y herramientas interactúen con el contenido de su espacio de trabajo.",
"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",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Use su clave API para la autenticación. Puede gestionar las claves API en la configuración de su cuenta.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante 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": "Toggle enforce OAuth for MCP",
"Supported tools": "Herramientas compatibles",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Su espacio de trabajo tiene MCP habilitado. Use su clave API para conectar asistentes de IA.",
"MCP server URL:": "URL del servidor MCP:",
"Learn more": "Más información",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Gestiona las claves de API para todos los usuarios en el espacio de trabajo. Consulta la
documentación de la API para detalles de uso.",
@@ -1186,8 +1191,8 @@
"Default value": "Valor predeterminado",
"Delete property": "Eliminar propiedad",
"Delete view": "Eliminar vista",
- "Delete {{count}} rows?_one": "Delete 1 row?",
- "Delete {{count}} rows?_other": "Delete {{count}} rows?",
+ "Delete {{count}} rows?_one": "¿Eliminar 1 fila?",
+ "Delete {{count}} rows?_other": "¿Eliminar {{count}} filas?",
"Descending": "Descendente",
"Discard": "Descartar",
"Doesn't contain": "No contiene",
@@ -1286,8 +1291,51 @@
"Value": "Valor",
"View updated for everyone": "Vista actualizada para todos",
"You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?",
- "{{count}} rows deleted_one": "1 row deleted",
- "{{count}} rows deleted_other": "{{count}} rows deleted",
- "{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} rows deleted_one": "1 fila eliminada",
+ "{{count}} rows deleted_other": "{{count}} filas eliminadas",
+ "{{count}} selected_one": "1 seleccionado",
+ "{{count}} selected_other": "{{count}} seleccionadas",
+ "Compare": "Comparar",
+ "Compare versions": "Comparar versiones",
+ "Select version from {{date}}": "Seleccionar la versión del {{date}}",
+ "Version actions for {{date}}": "Acciones de la versión del {{date}}",
+ "Comparing {{newer}} and {{older}}": "Comparando {{newer}} y {{older}}",
+ "Exit compare": "Salir de la comparación",
+ "Search attachments...": "Buscar archivos adjuntos...",
+ "Error loading attachments.": "Error al cargar los archivos adjuntos.",
+ "No attachments on this page yet.": "Todavía no hay archivos adjuntos en esta página.",
+ "Uploaded by {{name}}": "Subido por {{name}}",
+ "Download {{name}}": "Descargar {{name}}",
+ "Access revoked": "Acceso revocado",
+ "Authorize application": "Autorizar aplicación",
+ "{{name}} wants to access {{workspace}}": "{{name}} quiere acceder a {{workspace}}",
+ "Not you? Switch account": "¿No eres tú? Cambiar de cuenta",
+ "This application will be able to:": "Esta aplicación podrá:",
+ "Write": "Escribir",
+ "Invalid authorization request": "Solicitud de autorización no válida",
+ "Authorize": "Autorizar",
+ "Application": "Aplicación",
+ "Permissions": "Permisos",
+ "Authorized": "Autorizado",
+ "Revoke access": "Revocar acceso",
+ "Revoke access for {{name}}": "Revocar acceso para {{name}}",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "¿Seguro que quieres revocar el acceso de {{name}}? La aplicación ya no podrá acceder a tu cuenta.",
+ "Something went wrong. Please try again.": "Algo salió mal. Inténtalo de nuevo.",
+ "Remove {{name}}": "Eliminar {{name}}",
+ "Make sure you trust this application before authorizing it.": "Asegúrate de confiar en esta aplicación antes de autorizarla.",
+ "You will be redirected to": "Serás redirigido a",
+ "View content without making changes.": "Ver contenido sin realizar cambios.",
+ "Create and modify content.": "Crear y modificar contenido.",
+ "Applications and AI assistants you have authorized to access your account.": "Aplicaciones y asistentes de IA que has autorizado para acceder a tu cuenta.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Tu espacio de trabajo tiene MCP habilitado. Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.",
+ "Authorized apps": "Aplicaciones autorizadas",
+ "No authorized apps yet.": "Todavía no hay aplicaciones autorizadas.",
+ "Workspace knowledge only": "Solo conocimiento del espacio de trabajo",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restringe AI Chat para que responda solo con información de las páginas y archivos subidos de tu espacio de trabajo. No usará conocimiento externo.",
+ "Toggle workspace knowledge only": "Alternar solo conocimiento del espacio de trabajo",
+ "Read-only mode": "Modo de solo lectura",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat puede buscar y leer el contenido del espacio de trabajo, pero no puede crear ni editar páginas.",
+ "Toggle AI Chat read-only mode": "Alternar modo de solo lectura de AI Chat",
+ "Title only": "Solo título",
+ "you": "tú"
}
diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json
index e9fbeed06..eb1a43901 100644
--- a/apps/client/public/locales/fr-FR/translation.json
+++ b/apps/client/public/locales/fr-FR/translation.json
@@ -294,6 +294,7 @@
"Export space": "Exporter l'espace",
"Export {{type}}": "Exporter {{type}}",
"File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes",
+ "Media": "Médias",
"Align left": "Aligner à gauche",
"Align right": "Aligner à droite",
"Align center": "Aligner au centre",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"Page break": "Saut de page",
"Insert a page break for printing.": "Insérer un saut de page pour l’impression.",
+ "Footnote": "Note de bas de page",
+ "Insert a footnote reference.": "Insérer une référence de note de bas de page.",
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Activez le serveur MCP pour permettre aux assistants et outils IA d'interagir avec le contenu de votre espace de travail.",
"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",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Utilisez votre clé API pour l'authentification. Vous pouvez gérer les clés API dans les paramètres de votre compte.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via 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": "Toggle enforce OAuth for MCP",
"Supported tools": "Outils pris en charge",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Votre espace de travail a MCP activé. Utilisez votre clé API pour connecter des assistants IA.",
"MCP server URL:": "URL du serveur MCP :",
"Learn more": "En savoir plus",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Gérez les clés API pour tous les utilisateurs de l'espace de travail. Consultez la
documentation API pour plus de détails sur l'utilisation.",
@@ -1186,8 +1191,8 @@
"Default value": "Valeur par défaut",
"Delete property": "Supprimer la propriété",
"Delete view": "Supprimer la vue",
- "Delete {{count}} rows?_one": "Delete 1 row?",
- "Delete {{count}} rows?_other": "Delete {{count}} rows?",
+ "Delete {{count}} rows?_one": "Supprimer 1 ligne ?",
+ "Delete {{count}} rows?_other": "Supprimer {{count}} lignes ?",
"Descending": "Décroissant",
"Discard": "Ignorer",
"Doesn't contain": "Ne contient pas",
@@ -1286,8 +1291,51 @@
"Value": "Valeur",
"View updated for everyone": "Vue mise à jour pour tout le monde",
"You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?",
- "{{count}} rows deleted_one": "1 row deleted",
- "{{count}} rows deleted_other": "{{count}} rows deleted",
- "{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} rows deleted_one": "1 ligne supprimée",
+ "{{count}} rows deleted_other": "{{count}} lignes supprimées",
+ "{{count}} selected_one": "1 sélectionné(e)",
+ "{{count}} selected_other": "{{count}} sélectionné(e)s",
+ "Compare": "Comparer",
+ "Compare versions": "Comparer les versions",
+ "Select version from {{date}}": "Sélectionner la version du {{date}}",
+ "Version actions for {{date}}": "Actions de version pour le {{date}}",
+ "Comparing {{newer}} and {{older}}": "Comparaison de {{newer}} et {{older}}",
+ "Exit compare": "Quitter la comparaison",
+ "Search attachments...": "Rechercher des pièces jointes...",
+ "Error loading attachments.": "Erreur lors du chargement des pièces jointes.",
+ "No attachments on this page yet.": "Aucune pièce jointe sur cette page pour le moment.",
+ "Uploaded by {{name}}": "Téléversé par {{name}}",
+ "Download {{name}}": "Télécharger {{name}}",
+ "Access revoked": "Accès révoqué",
+ "Authorize application": "Autoriser l’application",
+ "{{name}} wants to access {{workspace}}": "{{name}} souhaite accéder à {{workspace}}",
+ "Not you? Switch account": "Ce n’est pas vous ? Changer de compte",
+ "This application will be able to:": "Cette application pourra :",
+ "Write": "Écriture",
+ "Invalid authorization request": "Demande d’autorisation invalide",
+ "Authorize": "Autoriser",
+ "Application": "Application",
+ "Permissions": "Autorisations",
+ "Authorized": "Autorisé",
+ "Revoke access": "Révoquer l’accès",
+ "Revoke access for {{name}}": "Révoquer l’accès pour {{name}}",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Êtes-vous sûr de vouloir révoquer l’accès pour {{name}} ? L’application ne pourra plus accéder à votre compte.",
+ "Something went wrong. Please try again.": "Une erreur s’est produite. Veuillez réessayer.",
+ "Remove {{name}}": "Supprimer {{name}}",
+ "Make sure you trust this application before authorizing it.": "Assurez-vous de faire confiance à cette application avant de l’autoriser.",
+ "You will be redirected to": "Vous serez redirigé vers",
+ "View content without making changes.": "Afficher le contenu sans apporter de modifications.",
+ "Create and modify content.": "Créer et modifier du contenu.",
+ "Applications and AI assistants you have authorized to access your account.": "Applications et assistants IA que vous avez autorisés à accéder à votre compte.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP est activé dans votre espace de travail. Connectez des assistants IA à votre compte Docmost via OAuth.",
+ "Authorized apps": "Applications autorisées",
+ "No authorized apps yet.": "Aucune application autorisée pour le moment.",
+ "Workspace knowledge only": "Connaissances de l’espace de travail uniquement",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Limiter AI Chat aux réponses provenant uniquement des pages de votre espace de travail et des fichiers téléversés. Il n’utilisera pas de connaissances externes.",
+ "Toggle workspace knowledge only": "Activer/désactiver les connaissances de l’espace de travail uniquement",
+ "Read-only mode": "Mode lecture seule",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat peut rechercher et lire le contenu de l’espace de travail, mais ne peut pas créer ni modifier des pages.",
+ "Toggle AI Chat read-only mode": "Activer/désactiver le mode lecture seule d’AI Chat",
+ "Title only": "Titre uniquement",
+ "you": "vous"
}
diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json
index 81eb1727f..493ff1add 100644
--- a/apps/client/public/locales/it-IT/translation.json
+++ b/apps/client/public/locales/it-IT/translation.json
@@ -205,7 +205,7 @@
"Templates": "Modelli",
"Theme": "Tema",
"To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.",
- "Toggle full page width": "Attiva/disattiva larghezza completa della pagina",
+ "Toggle full page width": "Attiva/disattiva larghezza intera della pagina",
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
"untitled": "senza titolo",
"Untitled": "Senza titolo",
@@ -294,6 +294,7 @@
"Export space": "Esporta spazio",
"Export {{type}}": "Esporta {{type}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
+ "Media": "Contenuti multimediali",
"Align left": "Allinea a sinistra",
"Align right": "Allinea a destra",
"Align center": "Allinea al centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Page break": "Interruzione di pagina",
"Insert a page break for printing.": "Inserisci un'interruzione di pagina per la stampa.",
+ "Footnote": "Nota a piè di pagina",
+ "Insert a footnote reference.": "Inserisci un riferimento a una nota a piè di pagina.",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Abilita il server MCP per consentire ad assistenti e strumenti IA di interagire con i contenuti del tuo spazio di lavoro.",
"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",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Usa la tua chiave API per l'autenticazione. Puoi gestire le chiavi API nelle impostazioni del tuo account.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite 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": "Toggle enforce OAuth for MCP",
"Supported tools": "Strumenti supportati",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Il tuo spazio di lavoro ha MCP abilitato. Usa la tua chiave API per collegare gli assistenti IA.",
"MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Gestisci le API key per tutti gli utenti nello spazio di lavoro. Consulta la
documentazione API per i dettagli sull'utilizzo.",
@@ -1186,8 +1191,8 @@
"Default value": "Valore predefinito",
"Delete property": "Elimina proprietà",
"Delete view": "Elimina vista",
- "Delete {{count}} rows?_one": "Delete 1 row?",
- "Delete {{count}} rows?_other": "Delete {{count}} rows?",
+ "Delete {{count}} rows?_one": "Eliminare 1 riga?",
+ "Delete {{count}} rows?_other": "Eliminare {{count}} righe?",
"Descending": "Decrescente",
"Discard": "Ignora",
"Doesn't contain": "Non contiene",
@@ -1286,8 +1291,51 @@
"Value": "Valore",
"View updated for everyone": "Vista aggiornata per tutti",
"You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?",
- "{{count}} rows deleted_one": "1 row deleted",
- "{{count}} rows deleted_other": "{{count}} rows deleted",
- "{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} rows deleted_one": "1 riga eliminata",
+ "{{count}} rows deleted_other": "{{count}} righe eliminate",
+ "{{count}} selected_one": "1 selezionato",
+ "{{count}} selected_other": "{{count}} selezionati",
+ "Compare": "Confronta",
+ "Compare versions": "Confronta versioni",
+ "Select version from {{date}}": "Seleziona la versione del {{date}}",
+ "Version actions for {{date}}": "Azioni della versione del {{date}}",
+ "Comparing {{newer}} and {{older}}": "Confronto tra {{newer}} e {{older}}",
+ "Exit compare": "Esci dal confronto",
+ "Search attachments...": "Cerca allegati...",
+ "Error loading attachments.": "Errore durante il caricamento degli allegati.",
+ "No attachments on this page yet.": "Ancora nessun allegato in questa pagina.",
+ "Uploaded by {{name}}": "Caricato da {{name}}",
+ "Download {{name}}": "Scarica {{name}}",
+ "Access revoked": "Accesso revocato",
+ "Authorize application": "Autorizza applicazione",
+ "{{name}} wants to access {{workspace}}": "{{name}} vuole accedere a {{workspace}}",
+ "Not you? Switch account": "Non sei tu? Cambia account",
+ "This application will be able to:": "Questa applicazione potrà:",
+ "Write": "Scrivere",
+ "Invalid authorization request": "Richiesta di autorizzazione non valida",
+ "Authorize": "Autorizza",
+ "Application": "Applicazione",
+ "Permissions": "Autorizzazioni",
+ "Authorized": "Autorizzato",
+ "Revoke access": "Revoca accesso",
+ "Revoke access for {{name}}": "Revoca l'accesso per {{name}}",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Sei sicuro di voler revocare l'accesso per {{name}}? L'applicazione non potrà più accedere al tuo account.",
+ "Something went wrong. Please try again.": "Qualcosa è andato storto. Riprova.",
+ "Remove {{name}}": "Rimuovi {{name}}",
+ "Make sure you trust this application before authorizing it.": "Assicurati di fidarti di questa applicazione prima di autorizzarla.",
+ "You will be redirected to": "Verrai reindirizzato a",
+ "View content without making changes.": "Visualizza il contenuto senza apportare modifiche.",
+ "Create and modify content.": "Crea e modifica contenuti.",
+ "Applications and AI assistants you have authorized to access your account.": "Applicazioni e assistenti AI che hai autorizzato ad accedere al tuo account.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Il tuo workspace ha MCP abilitato. Connetti gli assistenti AI al tuo account Docmost tramite OAuth.",
+ "Authorized apps": "App autorizzate",
+ "No authorized apps yet.": "Ancora nessuna app autorizzata.",
+ "Workspace knowledge only": "Solo conoscenze del workspace",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Limita AI Chat a rispondere solo in base alle pagine del tuo workspace e ai file caricati. Non userà conoscenze esterne.",
+ "Toggle workspace knowledge only": "Attiva/disattiva solo conoscenze del workspace",
+ "Read-only mode": "Modalità di sola lettura",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat può cercare e leggere i contenuti del workspace, ma non può creare o modificare pagine.",
+ "Toggle AI Chat read-only mode": "Attiva/disattiva la modalità di sola lettura di AI Chat",
+ "Title only": "Solo titolo",
+ "you": "tu"
}
diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json
index c1bfa1652..d14b06fc7 100644
--- a/apps/client/public/locales/ja-JP/translation.json
+++ b/apps/client/public/locales/ja-JP/translation.json
@@ -294,6 +294,7 @@
"Export space": "エクスポートスペース",
"Export {{type}}": "{{type}}をエクスポート",
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
+ "Media": "メディア",
"Align left": "左揃え",
"Align right": "右揃え",
"Align center": "中央揃え",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "区切り線を挿入します",
"Page break": "改ページ",
"Insert a page break for printing.": "印刷用に改ページを挿入します。",
+ "Footnote": "脚注",
+ "Insert a footnote reference.": "脚注参照を挿入します。",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "MCP サーバーを有効にして、AI アシスタントやツールがワークスペースのコンテンツとやり取りできるようにします。",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。",
"MCP Server URL": "MCP サーバーの URL",
- "Use your API key for authentication. You can manage API keys in your account settings.": "認証には API キーを使用してください。API キーはアカウント設定で管理できます。",
+ "Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
+ "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": "Toggle enforce OAuth for MCP",
"Supported tools": "サポートされているツール",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "このワークスペースでは MCP が有効になっています。AI アシスタントを接続するには API キーを使用してください。",
"MCP server URL:": "MCP サーバーの URL:",
"Learn more": "詳細を見る",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "ワークスペース内のすべてのユーザーのAPIキーを管理します。利用方法の詳細は
APIドキュメント をご覧ください。",
@@ -1289,5 +1294,48 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} selected_other": "{{count}} selected",
+ "Compare": "比較",
+ "Compare versions": "バージョンを比較",
+ "Select version from {{date}}": "{{date}} のバージョンを選択",
+ "Version actions for {{date}}": "{{date}} のバージョンの操作",
+ "Comparing {{newer}} and {{older}}": "{{newer}} と {{older}} を比較中",
+ "Exit compare": "比較を終了",
+ "Search attachments...": "添付ファイルを検索…",
+ "Error loading attachments.": "添付ファイルの読み込み中にエラーが発生しました。",
+ "No attachments on this page yet.": "このページにはまだ添付ファイルがありません。",
+ "Uploaded by {{name}}": "アップロード者: {{name}}",
+ "Download {{name}}": "{{name}} をダウンロード",
+ "Access revoked": "アクセスが取り消されました",
+ "Authorize application": "アプリケーションを認可",
+ "{{name}} wants to access {{workspace}}": "{{name}} が {{workspace}} へのアクセスを求めています",
+ "Not you? Switch account": "あなたではありませんか? アカウントを切り替え",
+ "This application will be able to:": "このアプリケーションで可能なこと:",
+ "Write": "書き込み",
+ "Invalid authorization request": "無効な認可リクエストです",
+ "Authorize": "認可",
+ "Application": "アプリケーション",
+ "Permissions": "権限",
+ "Authorized": "認可済み",
+ "Revoke access": "アクセスを取り消す",
+ "Revoke access for {{name}}": "{{name}} のアクセスを取り消す",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "{{name}} のアクセスを取り消してもよろしいですか? このアプリケーションは今後あなたのアカウントにアクセスできなくなります。",
+ "Something went wrong. Please try again.": "問題が発生しました。もう一度お試しください。",
+ "Remove {{name}}": "{{name}} を削除",
+ "Make sure you trust this application before authorizing it.": "認可する前に、このアプリケーションを信頼できることを確認してください。",
+ "You will be redirected to": "次へリダイレクトされます",
+ "View content without making changes.": "変更を加えずにコンテンツを表示します。",
+ "Create and modify content.": "コンテンツを作成および変更します。",
+ "Applications and AI assistants you have authorized to access your account.": "あなたのアカウントへのアクセスを認可したアプリケーションと AI アシスタント。",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "このワークスペースでは MCP が有効になっています。OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
+ "Authorized apps": "認可済みアプリ",
+ "No authorized apps yet.": "認可済みアプリはまだありません。",
+ "Workspace knowledge only": "ワークスペースの知識のみ",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "AIチャットの回答を、ワークスペース内のページとアップロードされたファイルのみに制限します。外部の知識は使用されません。",
+ "Toggle workspace knowledge only": "「ワークスペースの知識のみ」を切り替え",
+ "Read-only mode": "読み取り専用モード",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AIチャットはワークスペースのコンテンツを検索および閲覧できますが、ページの作成や編集はできません。",
+ "Toggle AI Chat read-only mode": "AIチャットの読み取り専用モードを切り替え",
+ "Title only": "タイトルのみ",
+ "you": "あなた"
}
diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json
index a7d795454..829fa8146 100644
--- a/apps/client/public/locales/ko-KR/translation.json
+++ b/apps/client/public/locales/ko-KR/translation.json
@@ -2,23 +2,23 @@
"Account": "계정",
"Active": "활성",
"Add": "추가",
- "Add group members": "팀에 사용자 추가",
- "Add groups": "팀 생성",
+ "Add group members": "그룹에 멤버 추가",
+ "Add groups": "그룹 추가",
"Add members": "사용자 추가",
- "Add to groups": "팀에 추가",
+ "Add to groups": "그룹에 추가",
"Add space members": "Space에 사용자 추가",
"Add to favorites": "즐겨찾기에 추가",
"Admin": "관리자",
- "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 팀을 삭제하시겠습니까? 해당 팀에 속한 사용자들은 이 팀이 가진 모든 권한을 잃게 됩니다.",
+ "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 그룹을 삭제하시겠습니까? 그룹 멤버는 이 그룹이 가진 모든 권한을 잃게 됩니다.",
"Are you sure you want to delete this page?": "이 페이지를 삭제하시겠습니까?",
- "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.": "이 사용자를 Space에서 제거하시겠습니까? 사용자는 이 Space에 대한 모든 접근 권한을 잃게 됩니다.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.",
- "Can become members of groups and spaces in workspace": "Workspace 내 팀 및 Space의 사용자가 될 수 있습니다.",
+ "Can become members of groups and spaces in workspace": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있음",
"Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.",
"Can edit": "편집할 수 있음",
- "Can manage workspace": "Workspace를 관리할 수 있음",
- "Can manage workspace but cannot delete it": "Workspace를 관리할 수 있지만, 삭제는 불가능.",
+ "Can manage workspace": "워크스페이스를 관리할 수 있음",
+ "Can manage workspace but cannot delete it": "워크스페이스를 관리할 수 있지만, 삭제할 수 없음",
"Can view": "볼 수 있음",
"Can view pages in space but not edit.": "Space의 페이지를 볼 수 있지만, 편집은 불가능.",
"Cancel": "취소",
@@ -33,17 +33,17 @@
"Copy as Markdown": "Markdown으로 복사",
"Copy link": "링크 복사",
"Create": "생성",
- "Create group": "팀 생성",
+ "Create group": "그룹 생성",
"Create page": "페이지 생성",
"Create space": "Space 생성",
- "Create workspace": "Workspace 생성",
+ "Create workspace": "워크스페이스 생성",
"Current password": "현재 비밀번호",
"Dark": "어두운",
"Date": "날짜",
"Delete": "삭제",
"Remove from page": "페이지에서 제거",
"Base options": "베이스 옵션",
- "Delete group": "팀 삭제",
+ "Delete group": "그룹 삭제",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.",
"Description": "설명",
"Details": "세부사항",
@@ -58,7 +58,7 @@
"e.g Space for sales team to collaborate": "예: 영업 팀이 협업하는 스페이스",
"Edit": "편집",
"Read": "읽기",
- "Edit group": "팀 편집",
+ "Edit group": "그룹 편집",
"Email": "이메일",
"Enter a strong password": "강력한 비밀번호를 입력하세요",
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
@@ -103,10 +103,10 @@
"Full page width": "전체 페이지 너비",
"Full width": "전체 너비",
"General": "일반",
- "Group": "팀",
- "Group description": "팀 설명",
- "Group name": "팀 이름",
- "Groups": "팀",
+ "Group": "그룹",
+ "Group description": "그룹 설명",
+ "Group name": "그룹 이름",
+ "Groups": "그룹",
"Has full access to space settings and pages.": "Space 설정과 페이지에 대한 전체 접근 권한이 있습니다.",
"Home": "홈",
"Import pages": "페이지 가져오기",
@@ -119,14 +119,14 @@
"Invite new members": "새 사용자 초대",
"Invite People": "사용자 초대",
"Invited members who are yet to accept their invitation will appear here.": "초대를 아직 수락하지 않은 초대된 사용자가 여기에 표시됩니다.",
- "Invited members will be granted access to spaces the groups can access": "초대된 사용자는 팀이 접근할 수 있는 Space에 대한 접근 권한을 받게 됩니다",
- "Join the workspace": "Workspace 참여",
+ "Invited members will be granted access to spaces the groups can access": "초대된 멤버에게는 그룹이 접근할 수 있는 스페이스에 대한 액세스 권한이 부여됩니다",
+ "Join the workspace": "워크스페이스 참여",
"Language": "언어",
"Light": "밝은",
"Link copied": "링크 복사됨",
"Login": "로그인",
"Logout": "로그아웃",
- "Manage Group": "팀 관리",
+ "Manage Group": "그룹 관리",
"Manage members": "사용자 관리",
"member": "사용자",
"Member": "사용자",
@@ -139,7 +139,7 @@
"New email": "새 이메일",
"New page": "새 페이지",
"New password": "새 비밀번호",
- "No group found": "그룹을 찾을 수 없습니다",
+ "No group found": "그룹을 찾을 수 없음",
"No page history saved yet.": "아직 저장된 페이지 기록이 없습니다.",
"No pages yet": "아직 페이지가 없습니다",
"No shared pages": "공유된 페이지가 없습니다.",
@@ -148,7 +148,7 @@
"Overview": "개요",
"Owner": "소유자",
"page": "페이지",
- "Page deleted successfully": "페이지 삭제 완료",
+ "Page deleted successfully": "페이지 삭제됨",
"Page history": "페이지 기록",
"Select version": "버전 선택",
"Highlight changes": "변경 사항 강조",
@@ -156,7 +156,7 @@
"Pages": "페이지",
"pages": "페이지",
"Password": "비밀번호",
- "Password changed successfully": "비밀번호 변경 완료",
+ "Password changed successfully": "비밀번호 변경됨",
"People": "사용자",
"Pending": "대기 중",
"Please confirm your action": "작업을 확인해 주세요",
@@ -166,15 +166,15 @@
"Recently updated": "최근 업데이트",
"Remove": "제거",
"Remove from favorites": "즐겨찾기에서 제거",
- "Remove group member": "팀에서 사용자 제거",
+ "Remove group member": "그룹 멤버 제거",
"Remove space member": "Space에서 사용자 제거",
"Restore": "복원",
"Role": "역할",
"Save": "저장",
"Search": "검색",
- "Search for groups": "팀 검색",
+ "Search for groups": "그룹 검색",
"Search for users": "사용자 검색",
- "Search for users and groups": "사용자 및 팀 검색",
+ "Search for users and groups": "사용자 및 그룹 검색",
"Search...": "검색...",
"Select language": "언어 선택",
"Select role": "역할 선택",
@@ -199,8 +199,8 @@
"Search for spaces": "스페이스 검색",
"Start typing to search...": "검색하려면 입력을 시작하세요...",
"Status": "상태",
- "Successfully imported": "가져오기에 성공했습니다",
- "Successfully restored": "복원에 성공했습니다",
+ "Successfully imported": "가져오기 완료",
+ "Successfully restored": "복원 완료",
"System settings": "시스템 설정",
"Templates": "템플릿",
"Theme": "테마",
@@ -209,7 +209,7 @@
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
"untitled": "제목 없음",
"Untitled": "제목 없음",
- "Updated successfully": "성공적으로 업데이트되었습니다",
+ "Updated successfully": "업데이트 완료",
"User": "사용자",
"Workspace": "워크스페이스",
"Workspace Name": "워크스페이스 이름",
@@ -245,15 +245,15 @@
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
"Delete chat": "채팅 삭제",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
- "Comment created successfully": "댓글 생성 완료",
+ "Comment created successfully": "댓글 생성됨",
"Error creating comment": "댓글 생성 오류",
- "Comment updated successfully": "댓글 업데이트 완료",
+ "Comment updated successfully": "댓글 업데이트됨",
"Failed to update comment": "댓글 업데이트 실패",
- "Comment deleted successfully": "댓글 삭제 완료",
+ "Comment deleted successfully": "댓글 삭제됨",
"Failed to delete comment": "댓글 삭제 실패",
- "Comment resolved successfully": "댓글 처리 완료",
- "Comment re-opened successfully": "댓글이 성공적으로 다시 열렸습니다",
- "Comment unresolved successfully": "댓글 해결이 성공적으로 취소되었습니다",
+ "Comment resolved successfully": "댓글이 해결로 표시됨",
+ "Comment re-opened successfully": "댓글이 열린 상태로 표시됨",
+ "Comment unresolved successfully": "댓글이 미해결로 표시됨",
"Failed to resolve comment": "댓글 처리 실패",
"Resolve comment": "댓글 해결",
"Unresolve comment": "댓글 해결 취소",
@@ -263,19 +263,19 @@
"Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?",
"Resolved": "해결됨",
"No active comments.": "활성 댓글이 없습니다.",
- "Revoke invitation": "초대 취소",
- "Revoke": "취소",
+ "Revoke invitation": "초대 폐기",
+ "Revoke": "폐기",
"Don't": "하지 않음",
- "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
+ "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 폐기하시겠습니까? 사용자가 워크스페이스에 참여할 수 없게 됩니다.",
"Resend invitation": "초대 재전송",
- "Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
+ "Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 워크스페이스에 참여할 수 있습니다.",
"Invite link": "초대 링크",
"Copy": "복사",
"Copy to space": "스페이스로 복사",
"Copied": "복사됨",
"Duplicate": "복제",
"Select a user": "사용자 선택",
- "Select a group": "팀 선택",
+ "Select a group": "그룹 선택",
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
"Delete space": "Space 삭제",
"Are you sure you want to delete this space?": "이 Space을 삭제하시겠습니까?",
@@ -290,10 +290,11 @@
"Export failed:": "내보내기 실패:",
"export error": "내보내기 오류",
"Export page": "페이지 내보내기",
- "Export successful": "내보내기 성공",
+ "Export successful": "내보내기 완료",
"Export space": "Space 내보내기",
"Export {{type}}": "{{type}} 내보내기",
"File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다",
+ "Media": "미디어",
"Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬",
"Align center": "가운데 정렬",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "가로 구분선 삽입",
"Page break": "페이지 나누기",
"Insert a page break for printing.": "인쇄용 페이지 나누기를 삽입합니다.",
+ "Footnote": "각주",
+ "Insert a footnote reference.": "각주 참조를 삽입합니다.",
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
@@ -447,12 +450,12 @@
"Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}",
"now": "지금",
- "Space created successfully": "스페이스가 성공적으로 생성되었습니다",
- "Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다",
- "Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다",
- "Members added successfully": "멤버가 성공적으로 추가되었습니다",
- "Member removed successfully": "멤버가 성공적으로 제거되었습니다",
- "Member role updated successfully": "멤버 역할이 성공적으로 업데이트되었습니다",
+ "Space created successfully": "스페이스 생성됨",
+ "Space updated successfully": "스페이스 업데이트됨",
+ "Space deleted successfully": "스페이스 삭제됨",
+ "Members added successfully": "멤버 추가됨",
+ "Member removed successfully": "멤버 삭제됨",
+ "Member role updated successfully": "멤버 역할 업데이트됨",
"Created by:
{{creatorName}} ": "작성자:
{{creatorName}} ",
"Created at: {{time}}": "작성 시간: {{time}}",
"Edited by {{name}} {{time}}": "{{name}}님이 {{time}}에 편집함",
@@ -465,7 +468,7 @@
"Choose {{format}} file": "{{format}} 파일 선택",
"Reading": "읽기",
"Delete member": "멤버 삭제",
- "Member deleted successfully": "멤버가 성공적으로 삭제되었습니다",
+ "Member deleted successfully": "멤버 삭제됨",
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Deactivate member": "멤버 비활성화",
"Activate member": "멤버 활성화",
@@ -498,32 +501,32 @@
"Delete share": "공유 삭제",
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
"Publicly shared pages from spaces you are a member of will appear here": "회원으로 속한 스페이스의 공개 공유 페이지가 여기에 표시됩니다",
- "Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
+ "Share deleted successfully": "공유 삭제됨",
"Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다",
- "Disable public sharing": "공유 비활성화",
- "Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
- "Toggle public sharing": "공유 전환",
- "Toggle space public sharing": "공간 공유 전환",
+ "Disable public sharing": "공개 공유 비활성화",
+ "Prevent members from sharing pages publicly.": "멤버가 페이지를 공개적으로 공유하지 못하게 합니다.",
+ "Toggle public sharing": "공개 공유 전환",
+ "Toggle space public sharing": "스페이스 공개 공유 전환",
"Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용",
"Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.",
"Toggle viewer comments": "뷰어 댓글 전환",
- "Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
- "Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
+ "Public sharing is disabled at the workspace level": "공개 공유가 워크스페이스 수준에서 비활성화됨",
+ "Prevent pages in this space from being shared publicly.": "이 스페이스의 페이지가 공개 공유되지 않도록 합니다.",
"Page permissions": "페이지 권한},{",
"Control who can view and edit individual pages. Available with an enterprise license.": "개별 페이지의 조회 및 편집 권한을 제어합니다. 엔터프라이즈 라이선스에서 이용 가능합니다.",
- "Enable public sharing": "공유 활성화",
- "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.",
- "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.",
- "Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?",
- "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.",
- "Public sharing is disabled": "공유가 비활성화되었습니다.",
- "Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
- "Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.",
+ "Enable public sharing": "공개 공유 활성화",
+ "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공개 공유를 활성화하시겠습니까? 멤버가 페이지를 공개적으로 공유할 수 있게 됩니다.",
+ "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 워크스페이스의 기존 공유 링크가 모두 삭제됩니다.",
+ "Are you sure you want to enable public sharing for this space?": "이 스페이스에 대해 공개 공유를 활성화하시겠습니까?",
+ "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 스페이스의 기존 공유 링크가 모두 삭제됩니다.",
+ "Public sharing is disabled": "공개 공유가 비활성화됨",
+ "Public sharing has been disabled at the workspace level.": "공개 공유가 워크스페이스 수준에서 비활성화되었습니다.",
+ "Public sharing has been disabled for this space.": "이 스페이스의 공개 공유가 비활성화되었습니다.",
"Copy page": "페이지 복사",
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
- "Page copied successfully": "페이지가 성공적으로 복사되었습니다",
- "Page duplicated successfully": "페이지가 성공적으로 복제되었습니다",
+ "Page copied successfully": "페이지 복사됨",
+ "Page duplicated successfully": "페이지 복제됨",
"Find": "찾기",
"Not found": "찾을 수 없음",
"Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)",
@@ -539,13 +542,13 @@
"Error": "오류",
"Failed to disable MFA": "MFA 비활성화에 실패했습니다",
"Disable two-factor authentication": "2단계 인증 비활성화",
- "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "이중 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
- "Please enter your password to disable two-factor authentication:": "이중 인증 비활성화를 위해 비밀번호를 입력하세요:",
- "Two-factor authentication has been enabled": "2단계 인증이 활성화되었습니다",
- "Two-factor authentication has been disabled": "2단계 인증이 비활성화되었습니다",
+ "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "2단계 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
+ "Please enter your password to disable two-factor authentication:": "2단계 인증 비활성화를 위해 비밀번호를 입력하세요:",
+ "Two-factor authentication has been enabled": "2단계 인증이 활성화됨",
+ "Two-factor authentication has been disabled": "2단계 인증이 비활성화됨",
"2-step verification": "2단계 인증",
"Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.",
- "Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.",
+ "Two-factor authentication is active on your account.": "2단계 인증이 계정에 활성화되어 있습니다.",
"Add 2FA method": "2FA 방법 추가",
"Backup codes": "백업 코드",
"Disable": "비활성화",
@@ -575,13 +578,13 @@
"Save your backup codes": "백업 코드를 저장하세요",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
"Print": "인쇄",
- "Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.",
- "Two-Factor authentication required": "2단계 인증이 필요합니다",
+ "Two-factor authentication has been set up. Please log in again.": "2단계 인증이 설정되었습니다. 다시 로그인해 주세요.",
+ "Two-Factor authentication required": "2단계 인증 필요",
"Your workspace requires two-factor authentication for all users": "이 워크스페이스는 모든 사용자에게 2단계 인증을 요구합니다",
- "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.",
+ "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스에 계속 액세스하려면 2단계 인증을 설정해야 합니다. 이는 계정의 보안을 한층 강화합니다.",
"Set up two-factor authentication": "2단계 인증 설정",
"Cancel and logout": "취소하고 로그아웃",
- "Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.",
+ "Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 2단계 인증이 필요합니다. 계속하려면 설정해 주세요.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.",
"Password is required": "비밀번호는 필수입니다",
"Password must be at least 8 characters": "비밀번호는 8자 이상이어야 합니다",
@@ -590,10 +593,10 @@
"Enter the 6-digit code found in your authenticator app": "인증 앱에 표시된 6자리 코드를 입력하세요",
"Need help authenticating?": "인증에 도움이 필요하십니까?",
"MFA QR Code": "MFA QR 코드",
- "Account created successfully. Please log in to set up two-factor authentication.": "계정이 성공적으로 생성되었습니다. 이중 인증을 설정하려면 로그인해 주세요.",
- "Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 완료하세요.",
- "Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 설정하세요.",
- "Password reset was successful. Please log in with your new password.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.",
+ "Account created successfully. Please log in to set up two-factor authentication.": "계정 생성됨. 2단계 인증을 설정하려면 로그인해 주세요.",
+ "Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정됨. 새 비밀번호로 로그인하여 2단계 인증을 완료하세요.",
+ "Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정됨. 새 비밀번호로 로그인하여 2단계 인증을 설정하세요.",
+ "Password reset was successful. Please log in with your new password.": "비밀번호 재설정됨. 새 비밀번호로 로그인하세요.",
"Two-factor authentication": "2단계 인증",
"Use authenticator app instead": "대신 인증 앱 사용",
"Verify backup code": "백업 코드 확인",
@@ -615,7 +618,7 @@
"Permanently delete": "영구 삭제",
"
{{name}} moved this page to Trash {{time}}.": "
{{name}} 님이 {{time}}에 이 페이지를 휴지통으로 이동했습니다.",
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
- "Page restored successfully": "페이지가 성공적으로 복원되었습니다",
+ "Page restored successfully": "페이지 복원됨",
"Deleted by": "삭제한 사람",
"Deleted at": "삭제 시간",
"Preview": "미리보기",
@@ -638,7 +641,7 @@
"Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.",
"Enter valid domain names separated by comma or space": "쉼표 또는 공백으로 구분된 유효한 도메인 이름을 입력하세요",
"Enforce two-factor authentication": "2단계 인증 강제",
- "Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 작업 공간에 액세스하기 위해 이중 인증을 활성화해야 합니다.",
+ "Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 워크스페이스에 액세스하기 위해 2단계 인증을 활성화해야 합니다.",
"Toggle MFA enforcement": "MFA 강제 설정 전환",
"Display name": "표시 이름",
"Allow signup": "가입 허용",
@@ -657,7 +660,7 @@
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
- "Image removed successfully": "이미지가 성공적으로 제거되었습니다",
+ "Image removed successfully": "이미지 삭제됨",
"API key": "API 키",
"API keys": "API 키",
"API management": "API 관리",
@@ -669,7 +672,7 @@
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
- "Revoked successfully": "성공적으로 취소되었습니다",
+ "Revoked successfully": "폐기 완료",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update": "업데이트",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "AI 어시스턴트와 도구가 워크스페이스 콘텐츠와 상호작용할 수 있도록 MCP 서버를 활성화하세요.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.",
"MCP Server URL": "MCP 서버 URL",
- "Use your API key for authentication. You can manage API keys in your account settings.": "인증을 위해 API 키를 사용하세요. API 키는 계정 설정에서 관리할 수 있습니다.",
+ "Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
+ "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": "Toggle enforce OAuth for MCP",
"Supported tools": "지원되는 도구",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "워크스페이스에 MCP가 활성화되어 있습니다. AI 어시스턴트를 연결하려면 API 키를 사용하세요.",
"MCP server URL:": "MCP 서버 URL:",
"Learn more": "자세히 알아보기",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "워크스페이스의 모든 사용자를 위한 API 키를 관리하세요. 사용 방법은
API 문서 를 참고하세요.",
@@ -939,8 +944,8 @@
"Try again": "다시 시도",
"Untitled chat": "제목 없는 채팅",
"What can I help you with?": "무엇을 도와드릴까요?",
- "Are you sure you want to revoke this {{credential}}": "이 {{credential}}을 취소하시겠습니까?",
- "Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자에서 사용자와 그룹을 자동으로 프로비저닝합니다.",
+ "Are you sure you want to revoke this {{credential}}": "이 {{credential}}을(를) 폐기하시겠습니까?",
+ "Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자로부터 사용자와 그룹을 자동으로 프로비저닝합니다.",
"Configure your identity provider with this URL to provision users and groups.": "사용자와 그룹을 프로비저닝할 수 있도록 이 URL로 ID 공급자를 구성하세요.",
"Create {{credential}}": "{{credential}} 만들기",
"{{credential}} created": "{{credential}} 생성됨",
@@ -953,7 +958,7 @@
"Important": "중요",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "지금 {{credential}}를 복사해 두세요. 다시는 볼 수 없습니다!",
"Never": "안 함",
- "Revoke {{credential}}": "{{credential}} 취소",
+ "Revoke {{credential}}": "{{credential}} 폐기",
"SCIM endpoint URL": "SCIM 엔드포인트 URL",
"SCIM provisioning": "SCIM 프로비저닝",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM이 활성화되어 있는 동안에는 SSO 그룹 동기화보다 SCIM이 우선 적용됩니다.",
@@ -1186,8 +1191,8 @@
"Default value": "기본값",
"Delete property": "속성 삭제",
"Delete view": "보기 삭제",
- "Delete {{count}} rows?_one": "Delete 1 row?",
- "Delete {{count}} rows?_other": "Delete {{count}} rows?",
+ "Delete {{count}} rows?_one": "1개 행을 삭제하시겠습니까?",
+ "Delete {{count}} rows?_other": "{{count}}개 행을 삭제하시겠습니까?",
"Descending": "내림차순",
"Discard": "삭제",
"Doesn't contain": "포함하지 않음",
@@ -1286,8 +1291,51 @@
"Value": "값",
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
- "{{count}} rows deleted_one": "1 row deleted",
- "{{count}} rows deleted_other": "{{count}} rows deleted",
- "{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} rows deleted_one": "1개 행 삭제 완료",
+ "{{count}} rows deleted_other": "{{count}}개 행 삭제 완료",
+ "{{count}} selected_one": "1개 선택됨",
+ "{{count}} selected_other": "{{count}}개 선택됨",
+ "Compare": "비교",
+ "Compare versions": "버전 비교",
+ "Select version from {{date}}": "{{date}}의 버전 선택",
+ "Version actions for {{date}}": "{{date}} 버전 작업",
+ "Comparing {{newer}} and {{older}}": "{{newer}} 및 {{older}} 비교 중",
+ "Exit compare": "비교 종료",
+ "Search attachments...": "첨부파일 검색...",
+ "Error loading attachments.": "첨부파일을 불러오는 중 오류가 발생했습니다.",
+ "No attachments on this page yet.": "이 페이지에는 아직 첨부파일이 없습니다.",
+ "Uploaded by {{name}}": "업로드한 사람: {{name}}",
+ "Download {{name}}": "{{name}} 다운로드",
+ "Access revoked": "액세스가 취소되었습니다",
+ "Authorize application": "애플리케이션 승인",
+ "{{name}} wants to access {{workspace}}": "{{name}}에서 {{workspace}}에 액세스하려고 합니다",
+ "Not you? Switch account": "본인이 아니신가요? 계정을 전환하세요",
+ "This application will be able to:": "이 애플리케이션이 수행할 수 있는 작업:",
+ "Write": "쓰기",
+ "Invalid authorization request": "잘못된 승인 요청",
+ "Authorize": "승인",
+ "Application": "애플리케이션",
+ "Permissions": "권한",
+ "Authorized": "승인됨",
+ "Revoke access": "액세스 취소",
+ "Revoke access for {{name}}": "{{name}}의 액세스 취소",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "정말로 {{name}}의 액세스를 취소하시겠습니까? 이 애플리케이션은 더 이상 회원님의 계정에 액세스할 수 없습니다.",
+ "Something went wrong. Please try again.": "문제가 발생했습니다. 다시 시도해 주세요.",
+ "Remove {{name}}": "{{name}} 제거",
+ "Make sure you trust this application before authorizing it.": "이 애플리케이션을 승인하기 전에 신뢰할 수 있는지 확인하세요.",
+ "You will be redirected to": "다음으로 리디렉션됩니다",
+ "View content without making changes.": "변경하지 않고 콘텐츠를 봅니다.",
+ "Create and modify content.": "콘텐츠를 생성하고 수정합니다.",
+ "Applications and AI assistants you have authorized to access your account.": "회원님의 계정에 액세스하도록 승인한 애플리케이션 및 AI 도우미입니다.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "워크스페이스에서 MCP가 활성화되어 있습니다. OAuth를 통해 AI 도우미를 Docmost 계정에 연결하세요.",
+ "Authorized apps": "승인된 앱",
+ "No authorized apps yet.": "아직 승인된 앱이 없습니다.",
+ "Workspace knowledge only": "워크스페이스 지식만 사용",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "AI 채팅이 워크스페이스 페이지와 업로드된 파일의 내용만 바탕으로 답변하도록 제한합니다. 외부 지식은 사용하지 않습니다.",
+ "Toggle workspace knowledge only": "워크스페이스 지식만 사용 전환",
+ "Read-only mode": "읽기 전용 모드",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI 채팅은 워크스페이스 콘텐츠를 검색하고 읽을 수 있지만, 페이지를 생성하거나 편집할 수는 없습니다.",
+ "Toggle AI Chat read-only mode": "AI 채팅 읽기 전용 모드 전환",
+ "Title only": "제목만",
+ "you": "회원님"
}
diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json
index 1f1465ab1..58199c144 100644
--- a/apps/client/public/locales/nl-NL/translation.json
+++ b/apps/client/public/locales/nl-NL/translation.json
@@ -294,6 +294,7 @@
"Export space": "Exporteer ruimte",
"Export {{type}}": "Exporteer {{type}}",
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
+ "Media": "Media",
"Align left": "Links uitlijnen",
"Align right": "Rechts uitlijnen",
"Align center": "Centreren",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"Page break": "Pagina-einde",
"Insert a page break for printing.": "Voeg een pagina-einde in voor het afdrukken.",
+ "Footnote": "Voetnoot",
+ "Insert a footnote reference.": "Voeg een voetnootverwijzing in.",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any audio from your device.": "Upload een audio vanaf uw apparaat.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Schakel de MCP-server in zodat AI-assistenten en tools kunnen interageren met de inhoud van uw werkruimte.",
"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",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Gebruik uw API-sleutel voor authenticatie. U kunt API-sleutels beheren in uw accountinstellingen.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via 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-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": "Toggle enforce OAuth for MCP",
"Supported tools": "Ondersteunde tools",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In uw werkruimte is MCP ingeschakeld. Gebruik uw API-sleutel om AI-assistenten te koppelen.",
"MCP server URL:": "MCP-server-URL:",
"Learn more": "Meer informatie",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Beheer API-sleutels voor alle gebruikers in de werkruimte. Bekijk de
API-documentatie voor gebruiksdetails.",
@@ -1289,5 +1294,48 @@
"{{count}} rows deleted_one": "1 rij verwijderd",
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
"{{count}} selected_one": "1 geselecteerd",
- "{{count}} selected_other": "{{count}} geselecteerd"
+ "{{count}} selected_other": "{{count}} geselecteerd",
+ "Compare": "Vergelijken",
+ "Compare versions": "Versies vergelijken",
+ "Select version from {{date}}": "Selecteer versie van {{date}}",
+ "Version actions for {{date}}": "Versieacties voor {{date}}",
+ "Comparing {{newer}} and {{older}}": "{{newer}} en {{older}} vergelijken",
+ "Exit compare": "Vergelijken afsluiten",
+ "Search attachments...": "Bijlagen zoeken...",
+ "Error loading attachments.": "Fout bij het laden van bijlagen.",
+ "No attachments on this page yet.": "Er zijn nog geen bijlagen op deze pagina.",
+ "Uploaded by {{name}}": "Geüpload door {{name}}",
+ "Download {{name}}": "{{name}} downloaden",
+ "Access revoked": "Toegang ingetrokken",
+ "Authorize application": "Applicatie autoriseren",
+ "{{name}} wants to access {{workspace}}": "{{name}} wil toegang tot {{workspace}}",
+ "Not you? Switch account": "Ben jij dit niet? Wissel van account",
+ "This application will be able to:": "Deze applicatie kan het volgende doen:",
+ "Write": "Schrijven",
+ "Invalid authorization request": "Ongeldig autorisatieverzoek",
+ "Authorize": "Autoriseren",
+ "Application": "Applicatie",
+ "Permissions": "Machtigingen",
+ "Authorized": "Geautoriseerd",
+ "Revoke access": "Toegang intrekken",
+ "Revoke access for {{name}}": "Toegang voor {{name}} intrekken",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Weet je zeker dat je de toegang voor {{name}} wilt intrekken? De applicatie heeft dan geen toegang meer tot je account.",
+ "Something went wrong. Please try again.": "Er is iets misgegaan. Probeer het opnieuw.",
+ "Remove {{name}}": "{{name}} verwijderen",
+ "Make sure you trust this application before authorizing it.": "Zorg ervoor dat je deze applicatie vertrouwt voordat je haar autoriseert.",
+ "You will be redirected to": "Je wordt doorgestuurd naar",
+ "View content without making changes.": "Inhoud bekijken zonder wijzigingen aan te brengen.",
+ "Create and modify content.": "Inhoud maken en wijzigen.",
+ "Applications and AI assistants you have authorized to access your account.": "Applicaties en AI-assistenten die je hebt geautoriseerd om toegang te krijgen tot je account.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP is ingeschakeld voor je werkruimte. Verbind AI-assistenten met je Docmost-account via OAuth.",
+ "Authorized apps": "Geautoriseerde apps",
+ "No authorized apps yet.": "Nog geen geautoriseerde apps.",
+ "Workspace knowledge only": "Alleen werkruimtek kennis",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Beperk AI Chat tot het beantwoorden op basis van alleen je werkruimtepagina's en geüploade bestanden. Er wordt geen externe kennis gebruikt.",
+ "Toggle workspace knowledge only": "Alleen werkruimtekennis in-/uitschakelen",
+ "Read-only mode": "Alleen-lezenmodus",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat kan werkruimte-inhoud doorzoeken en lezen, maar kan geen pagina's maken of bewerken.",
+ "Toggle AI Chat read-only mode": "Alleen-lezenmodus voor AI Chat in-/uitschakelen",
+ "Title only": "Alleen titel",
+ "you": "jij"
}
diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json
index 82f213703..7aa9c4fae 100644
--- a/apps/client/public/locales/pt-BR/translation.json
+++ b/apps/client/public/locales/pt-BR/translation.json
@@ -294,6 +294,7 @@
"Export space": "Exportar espaço",
"Export {{type}}": "Exportar para {{type}}",
"File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}",
+ "Media": "Mídia",
"Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Insira um divisor horizontal",
"Page break": "Quebra de página",
"Insert a page break for printing.": "Insira uma quebra de página para impressão.",
+ "Footnote": "Nota de rodapé",
+ "Insert a footnote reference.": "Inserir uma referência de nota de rodapé.",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Ative o servidor MCP para permitir que assistentes de IA e ferramentas interajam com o conteúdo do seu espaço de trabalho.",
"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",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Use sua chave de API para autenticação. Você pode gerenciar chaves de API nas configurações da sua conta.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via 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": "Toggle enforce OAuth for MCP",
"Supported tools": "Ferramentas compatíveis",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Seu espaço de trabalho tem MCP habilitado. Use sua chave de API para conectar assistentes de IA.",
"MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Gerencie as chaves de API de todos os usuários do workspace. Veja a
documentação da API para detalhes de uso.",
@@ -1289,5 +1294,48 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} selected_other": "{{count}} selected",
+ "Compare": "Comparar",
+ "Compare versions": "Comparar versões",
+ "Select version from {{date}}": "Selecionar versão de {{date}}",
+ "Version actions for {{date}}": "Ações da versão de {{date}}",
+ "Comparing {{newer}} and {{older}}": "Comparando {{newer}} e {{older}}",
+ "Exit compare": "Sair da comparação",
+ "Search attachments...": "Pesquisar anexos...",
+ "Error loading attachments.": "Erro ao carregar anexos.",
+ "No attachments on this page yet.": "Ainda não há anexos nesta página.",
+ "Uploaded by {{name}}": "Enviado por {{name}}",
+ "Download {{name}}": "Baixar {{name}}",
+ "Access revoked": "Acesso revogado",
+ "Authorize application": "Autorizar aplicativo",
+ "{{name}} wants to access {{workspace}}": "{{name}} quer acessar {{workspace}}",
+ "Not you? Switch account": "Não é você? Trocar conta",
+ "This application will be able to:": "Este aplicativo poderá:",
+ "Write": "Escrever",
+ "Invalid authorization request": "Solicitação de autorização inválida",
+ "Authorize": "Autorizar",
+ "Application": "Aplicativo",
+ "Permissions": "Permissões",
+ "Authorized": "Autorizado",
+ "Revoke access": "Revogar acesso",
+ "Revoke access for {{name}}": "Revogar acesso de {{name}}",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Tem certeza de que deseja revogar o acesso de {{name}}? O aplicativo não poderá mais acessar sua conta.",
+ "Something went wrong. Please try again.": "Algo deu errado. Tente novamente.",
+ "Remove {{name}}": "Remover {{name}}",
+ "Make sure you trust this application before authorizing it.": "Certifique-se de confiar neste aplicativo antes de autorizá-lo.",
+ "You will be redirected to": "Você será redirecionado para",
+ "View content without making changes.": "Visualizar conteúdo sem fazer alterações.",
+ "Create and modify content.": "Criar e modificar conteúdo.",
+ "Applications and AI assistants you have authorized to access your account.": "Aplicativos e assistentes de IA que você autorizou a acessar sua conta.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Seu workspace tem o MCP ativado. Conecte assistentes de IA à sua conta do Docmost via OAuth.",
+ "Authorized apps": "Aplicativos autorizados",
+ "No authorized apps yet.": "Ainda não há aplicativos autorizados.",
+ "Workspace knowledge only": "Somente conhecimento do workspace",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrinja o AI Chat para responder apenas com base nas páginas do seu workspace e nos arquivos enviados. Ele não usará conhecimento externo.",
+ "Toggle workspace knowledge only": "Alternar somente conhecimento do workspace",
+ "Read-only mode": "Modo somente leitura",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "O AI Chat pode pesquisar e ler o conteúdo do workspace, mas não pode criar nem editar páginas.",
+ "Toggle AI Chat read-only mode": "Alternar modo somente leitura do AI Chat",
+ "Title only": "Somente título",
+ "you": "você"
}
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 9d4ddc8a6..6893b33a7 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -294,6 +294,7 @@
"Export space": "Экспорт пространства",
"Export {{type}}": "Экспорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
+ "Media": "Медиа",
"Align left": "По левому краю",
"Align right": "По правому краю",
"Align center": "По центру",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Page break": "Разрыв страницы",
"Insert a page break for printing.": "Вставить разрыв страницы для печати.",
+ "Footnote": "Сноска",
+ "Insert a footnote reference.": "Вставить ссылку на сноску.",
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Включите сервер MCP, чтобы ИИ-ассистенты и инструменты могли взаимодействовать с содержимым вашего рабочего пространства.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Используйте ваш API-ключ для аутентификации. Управлять API-ключами можно в настройках аккаунта.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через 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": "Toggle enforce OAuth for MCP",
"Supported tools": "Поддерживаемые инструменты",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "В вашем рабочем пространстве включён MCP. Используйте свой API-ключ для подключения ИИ-ассистентов.",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Подробнее",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите
документацию по API для получения информации об использовании.",
@@ -1186,8 +1191,8 @@
"Default value": "Значение по умолчанию",
"Delete property": "Удалить свойство",
"Delete view": "Удалить представление",
- "Delete {{count}} rows?_one": "Delete 1 row?",
- "Delete {{count}} rows?_other": "Delete {{count}} rows?",
+ "Delete {{count}} rows?_one": "Удалить 1 строку?",
+ "Delete {{count}} rows?_other": "Удалить {{count}} строк?",
"Descending": "По убыванию",
"Discard": "Отменить",
"Doesn't contain": "Не содержит",
@@ -1286,8 +1291,51 @@
"Value": "Значение",
"View updated for everyone": "Представление обновлено для всех",
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
- "{{count}} rows deleted_one": "1 row deleted",
- "{{count}} rows deleted_other": "{{count}} rows deleted",
- "{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} rows deleted_one": "1 строка удалена",
+ "{{count}} rows deleted_other": "{{count}} строк удалено",
+ "{{count}} selected_one": "1 выбрано",
+ "{{count}} selected_other": "{{count}} выбрано",
+ "Compare": "Сравнить",
+ "Compare versions": "Сравнить версии",
+ "Select version from {{date}}": "Выбрать версию от {{date}}",
+ "Version actions for {{date}}": "Действия с версией от {{date}}",
+ "Comparing {{newer}} and {{older}}": "Сравнение {{newer}} и {{older}}",
+ "Exit compare": "Выйти из режима сравнения",
+ "Search attachments...": "Поиск вложений...",
+ "Error loading attachments.": "Ошибка при загрузке вложений.",
+ "No attachments on this page yet.": "На этой странице пока нет вложений.",
+ "Uploaded by {{name}}": "Загружено пользователем {{name}}",
+ "Download {{name}}": "Скачать {{name}}",
+ "Access revoked": "Доступ отозван",
+ "Authorize application": "Авторизовать приложение",
+ "{{name}} wants to access {{workspace}}": "{{name}} запрашивает доступ к {{workspace}}",
+ "Not you? Switch account": "Не вы? Сменить аккаунт",
+ "This application will be able to:": "Это приложение сможет:",
+ "Write": "Запись",
+ "Invalid authorization request": "Недопустимый запрос на авторизацию",
+ "Authorize": "Авторизовать",
+ "Application": "Приложение",
+ "Permissions": "Разрешения",
+ "Authorized": "Авторизовано",
+ "Revoke access": "Отозвать доступ",
+ "Revoke access for {{name}}": "Отозвать доступ для {{name}}",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Вы уверены, что хотите отозвать доступ для {{name}}? Приложение больше не сможет получить доступ к вашей учетной записи.",
+ "Something went wrong. Please try again.": "Что-то пошло не так. Пожалуйста, попробуйте еще раз.",
+ "Remove {{name}}": "Удалить {{name}}",
+ "Make sure you trust this application before authorizing it.": "Прежде чем авторизовать это приложение, убедитесь, что вы ему доверяете.",
+ "You will be redirected to": "Вы будете перенаправлены на",
+ "View content without making changes.": "Просматривать содержимое без внесения изменений.",
+ "Create and modify content.": "Создавать и изменять содержимое.",
+ "Applications and AI assistants you have authorized to access your account.": "Приложения и AI-помощники, которым вы разрешили доступ к своей учетной записи.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "В вашем рабочем пространстве включен MCP. Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.",
+ "Authorized apps": "Авторизованные приложения",
+ "No authorized apps yet.": "Пока нет авторизованных приложений.",
+ "Workspace knowledge only": "Только знания рабочего пространства",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Ограничить AI Chat ответами только на основе страниц вашего рабочего пространства и загруженных файлов. Внешние знания использоваться не будут.",
+ "Toggle workspace knowledge only": "Переключить режим \"Только знания рабочего пространства\"",
+ "Read-only mode": "Режим только для чтения",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat может искать и читать содержимое рабочего пространства, но не может создавать или редактировать страницы.",
+ "Toggle AI Chat read-only mode": "Переключить режим только для чтения для AI Chat",
+ "Title only": "Только заголовок",
+ "you": "вы"
}
diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json
index be09a646f..efb464649 100644
--- a/apps/client/public/locales/uk-UA/translation.json
+++ b/apps/client/public/locales/uk-UA/translation.json
@@ -294,6 +294,7 @@
"Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
+ "Media": "Медіа",
"Align left": "По лівому краю",
"Align right": "По правому краю",
"Align center": "По центру",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Page break": "Розрив сторінки",
"Insert a page break for printing.": "Вставте розрив сторінки для друку.",
+ "Footnote": "Виноска",
+ "Insert a footnote reference.": "Вставити посилання на виноску.",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Увімкніть MCP‑сервер, щоб дозволити ШІ‑помічникам та інструментам взаємодіяти з вмістом вашого робочого простору.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.",
"MCP Server URL": "URL сервера MCP",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Використовуйте свій API‑ключ для аутентифікації. Ви можете керувати API‑ключами в налаштуваннях облікового запису.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через 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": "Toggle enforce OAuth for MCP",
"Supported tools": "Підтримувані інструменти",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "У вашому робочому просторі MCP увімкнено. Використайте свій API‑ключ, щоб підключити ШІ‑помічників.",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Дізнатися більше",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "Керуйте ключами API для всіх користувачів у робочому просторі. Перегляньте
документацію API для деталей використання.",
@@ -1289,5 +1294,48 @@
"{{count}} rows deleted_one": "1 row deleted",
"{{count}} rows deleted_other": "{{count}} rows deleted",
"{{count}} selected_one": "1 selected",
- "{{count}} selected_other": "{{count}} selected"
+ "{{count}} selected_other": "{{count}} selected",
+ "Compare": "Порівняти",
+ "Compare versions": "Порівняти версії",
+ "Select version from {{date}}": "Виберіть версію від {{date}}",
+ "Version actions for {{date}}": "Дії з версією від {{date}}",
+ "Comparing {{newer}} and {{older}}": "Порівняння {{newer}} і {{older}}",
+ "Exit compare": "Вийти з режиму порівняння",
+ "Search attachments...": "Пошук вкладень...",
+ "Error loading attachments.": "Помилка завантаження вкладень.",
+ "No attachments on this page yet.": "На цій сторінці ще немає вкладень.",
+ "Uploaded by {{name}}": "Завантажено користувачем {{name}}",
+ "Download {{name}}": "Завантажити {{name}}",
+ "Access revoked": "Доступ відкликано",
+ "Authorize application": "Авторизувати застосунок",
+ "{{name}} wants to access {{workspace}}": "{{name}} хоче отримати доступ до {{workspace}}",
+ "Not you? Switch account": "Не ви? Змінити обліковий запис",
+ "This application will be able to:": "Цей застосунок зможе:",
+ "Write": "Запис",
+ "Invalid authorization request": "Недійсний запит на авторизацію",
+ "Authorize": "Авторизувати",
+ "Application": "Застосунок",
+ "Permissions": "Дозволи",
+ "Authorized": "Авторизовано",
+ "Revoke access": "Відкликати доступ",
+ "Revoke access for {{name}}": "Відкликати доступ для {{name}}",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Ви впевнені, що хочете відкликати доступ для {{name}}? Застосунок більше не зможе отримувати доступ до вашого облікового запису.",
+ "Something went wrong. Please try again.": "Щось пішло не так. Спробуйте ще раз.",
+ "Remove {{name}}": "Видалити {{name}}",
+ "Make sure you trust this application before authorizing it.": "Перш ніж авторизувати цей застосунок, переконайтеся, що ви йому довіряєте.",
+ "You will be redirected to": "Вас буде перенаправлено на",
+ "View content without making changes.": "Переглядати вміст без внесення змін.",
+ "Create and modify content.": "Створювати та змінювати вміст.",
+ "Applications and AI assistants you have authorized to access your account.": "Застосунки та AI-асистенти, яким ви надали дозвіл на доступ до свого облікового запису.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "У вашому робочому просторі ввімкнено MCP. Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.",
+ "Authorized apps": "Авторизовані застосунки",
+ "No authorized apps yet.": "Авторизованих застосунків ще немає.",
+ "Workspace knowledge only": "Лише знання робочого простору",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Обмежити AI Chat відповідями лише на основі сторінок вашого робочого простору та завантажених файлів. Зовнішні знання не використовуватимуться.",
+ "Toggle workspace knowledge only": "Перемкнути режим лише знань робочого простору",
+ "Read-only mode": "Режим лише читання",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat може шукати та читати вміст робочого простору, але не може створювати або редагувати сторінки.",
+ "Toggle AI Chat read-only mode": "Перемкнути режим лише читання для AI Chat",
+ "Title only": "Лише заголовок",
+ "you": "ви"
}
diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json
index 1b273e71c..8caa80788 100644
--- a/apps/client/public/locales/zh-CN/translation.json
+++ b/apps/client/public/locales/zh-CN/translation.json
@@ -294,6 +294,7 @@
"Export space": "导出空间",
"Export {{type}}": "导出为 {{type}}",
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
+ "Media": "媒体",
"Align left": "靠左对齐",
"Align right": "靠右对齐",
"Align center": "居中对齐",
@@ -387,6 +388,8 @@
"Insert horizontal rule divider": "插入水平分割线",
"Page break": "分页符",
"Insert a page break for printing.": "插入一个用于打印的分页符。",
+ "Footnote": "脚注",
+ "Insert a footnote reference.": "插入脚注引用。",
"Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频",
"Upload any audio from your device.": "从您的设备上传任意音频文件。",
@@ -704,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "启用 MCP 服务器以允许 AI 助手和工具与您的工作区内容交互。",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。",
"MCP Server URL": "MCP 服务器 URL",
- "Use your API key for authentication. You can manage API keys in your account settings.": "使用您的 API 密钥进行身份验证。您可以在账户设置中管理 API 密钥。",
+ "Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
+ "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": "Toggle enforce OAuth for MCP",
"Supported tools": "支持的工具",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "您的工作区已启用 MCP。使用您的 API 密钥连接 AI 助手。",
"MCP server URL:": "MCP 服务器 URL:",
"Learn more": "了解更多",
"Manage API keys for all users in the workspace. View the
API documentation for usage details.": "为工作区内所有用户管理 API 密钥。有关使用详情,请查阅
API 文档 。",
@@ -1289,5 +1294,48 @@
"{{count}} rows deleted_one": "已删除 1 行",
"{{count}} rows deleted_other": "已删除 {{count}} 行",
"{{count}} selected_one": "已选择 1 项",
- "{{count}} selected_other": "已选择 {{count}} 项"
+ "{{count}} selected_other": "已选择 {{count}} 项",
+ "Compare": "比较",
+ "Compare versions": "比较版本",
+ "Select version from {{date}}": "选择 {{date}} 的版本",
+ "Version actions for {{date}}": "{{date}} 的版本操作",
+ "Comparing {{newer}} and {{older}}": "正在比较 {{newer}} 和 {{older}}",
+ "Exit compare": "退出比较",
+ "Search attachments...": "搜索附件……",
+ "Error loading attachments.": "加载附件时出错。",
+ "No attachments on this page yet.": "此页面上还没有附件。",
+ "Uploaded by {{name}}": "由 {{name}} 上传",
+ "Download {{name}}": "下载 {{name}}",
+ "Access revoked": "访问权限已撤销",
+ "Authorize application": "授权应用程序",
+ "{{name}} wants to access {{workspace}}": "{{name}} 想要访问 {{workspace}}",
+ "Not you? Switch account": "不是你?切换账户",
+ "This application will be able to:": "此应用程序将能够:",
+ "Write": "写入",
+ "Invalid authorization request": "无效的授权请求",
+ "Authorize": "授权",
+ "Application": "应用程序",
+ "Permissions": "权限",
+ "Authorized": "已授权",
+ "Revoke access": "撤销访问权限",
+ "Revoke access for {{name}}": "撤销 {{name}} 的访问权限",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "你确定要撤销 {{name}} 的访问权限吗?该应用程序将无法再访问你的账户。",
+ "Something went wrong. Please try again.": "出了点问题。请重试。",
+ "Remove {{name}}": "移除 {{name}}",
+ "Make sure you trust this application before authorizing it.": "在授权之前,请确保你信任此应用程序。",
+ "You will be redirected to": "你将被重定向到",
+ "View content without making changes.": "查看内容而不进行更改。",
+ "Create and modify content.": "创建和修改内容。",
+ "Applications and AI assistants you have authorized to access your account.": "你已授权访问你账户的应用程序和 AI 助手。",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "你的工作区已启用 MCP。通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
+ "Authorized apps": "已授权的应用",
+ "No authorized apps yet.": "尚无已授权的应用。",
+ "Workspace knowledge only": "仅限工作区知识",
+ "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "将 AI 聊天限制为仅根据你的工作区页面和已上传文件进行回答。它不会使用外部知识。",
+ "Toggle workspace knowledge only": "切换“仅限工作区知识”",
+ "Read-only mode": "只读模式",
+ "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI 聊天可以搜索和读取工作区内容,但不能创建或编辑页面。",
+ "Toggle AI Chat read-only mode": "切换 AI 聊天只读模式",
+ "Title only": "仅标题",
+ "you": "你"
}
From 579e22a45e525cf10b4474daf9ad3fdd933932dd Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Wed, 26 Aug 2026 14:16:18 +0100
Subject: [PATCH 19/27] fix: use ACL username and decode credentials in Redis
URL parsing (#2434)
* fix: use ACL username and decode credentials in Redis URL parsing
* fix: pass family and TLS options to the collab app module
---
.../collaboration/collaboration.gateway.ts | 1 +
.../collaboration/server/collab-app.module.ts | 15 +++++++++++--
apps/server/src/common/helpers/utils.ts | 21 +++++++++++++++++--
.../src/integrations/queue/queue.module.ts | 1 +
.../redis/redis-config.service.ts | 1 +
.../integrations/throttle/throttle.module.ts | 1 +
6 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts
index 05536664e..254b8d430 100644
--- a/apps/server/src/collaboration/collaboration.gateway.ts
+++ b/apps/server/src/collaboration/collaboration.gateway.ts
@@ -63,6 +63,7 @@ export class CollaborationGateway {
redis: new RedisClient({
host: this.redisConfig.host,
port: this.redisConfig.port,
+ username: this.redisConfig.username,
password: this.redisConfig.password,
db: this.redisConfig.db,
family: this.redisConfig.family,
diff --git a/apps/server/src/collaboration/server/collab-app.module.ts b/apps/server/src/collaboration/server/collab-app.module.ts
index 85738d1cb..da48bfb94 100644
--- a/apps/server/src/collaboration/server/collab-app.module.ts
+++ b/apps/server/src/collaboration/server/collab-app.module.ts
@@ -14,7 +14,8 @@ import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
import { CaslModule } from '../../core/casl/casl.module';
import { CacheModule } from '@nestjs/cache-manager';
-import KeyvRedis from '@keyv/redis';
+import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis';
+import { parseRedisUrl } from '../../common/helpers';
@Module({
imports: [
@@ -33,10 +34,20 @@ import KeyvRedis from '@keyv/redis';
isGlobal: true,
useFactory: async (environmentService: EnvironmentService) => {
const redisUrl = environmentService.getRedisUrl();
+ const { family, tls } = parseRedisUrl(redisUrl);
return {
ttl: 5 * 1000,
- stores: [new KeyvRedis(redisUrl)],
+ stores: [
+ new KeyvRedis({
+ url: redisUrl,
+ socket: {
+ family,
+ reconnectStrategy: defaultReconnectStrategy,
+ ...tls,
+ },
+ }),
+ ],
};
},
inject: [EnvironmentService],
diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts
index 100d55d92..aebe05385 100644
--- a/apps/server/src/common/helpers/utils.ts
+++ b/apps/server/src/common/helpers/utils.ts
@@ -28,6 +28,7 @@ export type RedisConfig = {
host: string;
port: number;
db: number;
+ username?: string;
password?: string;
family?: number;
tls?: { rejectUnauthorized?: boolean };
@@ -36,7 +37,15 @@ export type RedisConfig = {
export function parseRedisUrl(redisUrl: string): RedisConfig {
// format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6][&rejectUnauthorized=false]
const url = new URL(redisUrl);
- const { hostname, port, password, pathname, protocol, searchParams } = url;
+ const {
+ hostname,
+ port,
+ username,
+ password,
+ pathname,
+ protocol,
+ searchParams,
+ } = url;
const portInt = port ? parseInt(port, 10) : 6379;
let db: number = 0;
@@ -62,7 +71,15 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
: {}
: undefined;
- return { host: hostname, port: portInt, password: password || undefined, db, family, tls };
+ return {
+ host: hostname,
+ port: portInt,
+ username: username ? decodeURIComponent(username) : undefined,
+ password: password ? decodeURIComponent(password) : undefined,
+ db,
+ family,
+ tls,
+ };
}
export function createRetryStrategy() {
diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts
index fcb317dbf..0c2c3c908 100644
--- a/apps/server/src/integrations/queue/queue.module.ts
+++ b/apps/server/src/integrations/queue/queue.module.ts
@@ -15,6 +15,7 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
connection: {
host: redisConfig.host,
port: redisConfig.port,
+ username: redisConfig.username,
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
diff --git a/apps/server/src/integrations/redis/redis-config.service.ts b/apps/server/src/integrations/redis/redis-config.service.ts
index 7f3e90174..c613e0389 100644
--- a/apps/server/src/integrations/redis/redis-config.service.ts
+++ b/apps/server/src/integrations/redis/redis-config.service.ts
@@ -16,6 +16,7 @@ export class RedisConfigService implements RedisOptionsFactory {
config: {
host: redisConfig.host,
port: redisConfig.port,
+ username: redisConfig.username,
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts
index e22eddc75..4c9537526 100644
--- a/apps/server/src/integrations/throttle/throttle.module.ts
+++ b/apps/server/src/integrations/throttle/throttle.module.ts
@@ -33,6 +33,7 @@ import Redis from 'ioredis';
new Redis({
host: redisConfig.host,
port: redisConfig.port,
+ username: redisConfig.username,
password: redisConfig.password,
db: redisConfig.db,
family: redisConfig.family,
From d92716d82fa0e8f540f39df73a67673c129de6a9 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Thu, 27 Aug 2026 12:43:08 +0100
Subject: [PATCH 20/27] feat: search group members (#2445)
---
.../group/components/group-members.tsx | 118 ++++++++++--------
.../database/repos/group/group-user.repo.ts | 4 +
2 files changed, 70 insertions(+), 52 deletions(-)
diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx
index 3bf04b5ac..b8003e01b 100644
--- a/apps/client/src/features/group/components/group-members.tsx
+++ b/apps/client/src/features/group/components/group-members.tsx
@@ -12,13 +12,19 @@ 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 { useCursorPaginate } from "@/hooks/use-cursor-paginate";
+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";
export default function GroupMembersList() {
const { t } = useTranslation();
const { groupId } = useParams();
- const { cursor, goNext, goPrev } = useCursorPaginate();
- const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
+ const { search, cursor, goNext, goPrev, handleSearch } =
+ usePaginateAndSearch();
+ const { data, isLoading } = useGroupMembersQuery(groupId, {
+ cursor,
+ query: search,
+ });
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
@@ -48,6 +54,7 @@ export default function GroupMembersList() {
return (
<>
+
@@ -59,55 +66,62 @@ export default function GroupMembersList() {
- {data?.items.map((user: IUser, index: number) => (
-
-
-
-
-
-
- {user.name}
-
-
- {user.email}
-
-
-
-
-
- {t("Active")}
-
-
- {isAdmin && (
-
-
-
-
-
-
-
- openRemoveModal(user.id)}>
- {t("Remove group member")}
-
-
-
- )}
-
-
- ))}
+ {data?.items.length > 0 ? (
+ data?.items.map((user: IUser, index: number) => (
+
+
+
+
+
+
+ {user.name}
+
+
+ {user.email}
+
+
+
+
+
+ {t("Active")}
+
+
+ {isAdmin && (
+
+
+
+
+
+
+
+ openRemoveModal(user.id)}>
+ {t("Remove group member")}
+
+
+
+ )}
+
+
+ ))
+ ) : (
+
+ )}
diff --git a/apps/server/src/database/repos/group/group-user.repo.ts b/apps/server/src/database/repos/group/group-user.repo.ts
index 08184e2c9..76b023f81 100644
--- a/apps/server/src/database/repos/group/group-user.repo.ts
+++ b/apps/server/src/database/repos/group/group-user.repo.ts
@@ -60,6 +60,10 @@ export class GroupUserRepo {
sql`f_unaccent(users.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
+ ).or(
+ sql`users.email`,
+ 'ilike',
+ sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
From 622d0855305a54800e4f781b41a3914bfea72cf6 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Thu, 27 Aug 2026 12:43:19 +0100
Subject: [PATCH 21/27] New Crowdin updates (#2435)
---
apps/client/public/locales/de-DE/translation.json | 4 ++--
apps/client/public/locales/es-ES/translation.json | 4 ++--
apps/client/public/locales/fr-FR/translation.json | 4 ++--
apps/client/public/locales/it-IT/translation.json | 4 ++--
apps/client/public/locales/ja-JP/translation.json | 10 +++++-----
apps/client/public/locales/ko-KR/translation.json | 4 ++--
apps/client/public/locales/nl-NL/translation.json | 4 ++--
apps/client/public/locales/pt-BR/translation.json | 4 ++--
apps/client/public/locales/ru-RU/translation.json | 4 ++--
apps/client/public/locales/uk-UA/translation.json | 4 ++--
apps/client/public/locales/zh-CN/translation.json | 4 ++--
11 files changed, 25 insertions(+), 25 deletions(-)
diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json
index 89bafb092..3a9272d1e 100644
--- a/apps/client/public/locales/de-DE/translation.json
+++ b/apps/client/public/locales/de-DE/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "OAuth erzwingen",
"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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "OAuth-Erzwingung für MCP umschalten",
"Supported tools": "Unterstützte Tools",
"MCP server URL:": "MCP-Server-URL:",
"Learn more": "Mehr erfahren",
diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json
index 1ee829218..d7093bf3e 100644
--- a/apps/client/public/locales/es-ES/translation.json
+++ b/apps/client/public/locales/es-ES/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "Exigir 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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "Activar o desactivar la exigencia de OAuth para MCP",
"Supported tools": "Herramientas compatibles",
"MCP server URL:": "URL del servidor MCP:",
"Learn more": "Más información",
diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json
index eb1a43901..76b80d4be 100644
--- a/apps/client/public/locales/fr-FR/translation.json
+++ b/apps/client/public/locales/fr-FR/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "Imposer 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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "Activer ou désactiver l’imposition d’OAuth pour MCP",
"Supported tools": "Outils pris en charge",
"MCP server URL:": "URL du serveur MCP :",
"Learn more": "En savoir plus",
diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json
index 493ff1add..24dddd97d 100644
--- a/apps/client/public/locales/it-IT/translation.json
+++ b/apps/client/public/locales/it-IT/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "Rendi obbligatorio 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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "Attiva/disattiva l'obbligo di OAuth per MCP",
"Supported tools": "Strumenti supportati",
"MCP server URL:": "URL del server MCP:",
"Learn more": "Scopri di più",
diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json
index d14b06fc7..f29f01b04 100644
--- a/apps/client/public/locales/ja-JP/translation.json
+++ b/apps/client/public/locales/ja-JP/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "MCP の OAuth 必須化を切り替え",
"Supported tools": "サポートされているツール",
"MCP server URL:": "MCP サーバーの URL:",
"Learn more": "詳細を見る",
@@ -1191,7 +1191,7 @@
"Default value": "デフォルト値",
"Delete property": "プロパティを削除",
"Delete view": "ビューを削除",
- "Delete {{count}} rows?_one": "Delete 1 row?",
+ "Delete {{count}} rows?_one": "1行を削除しますか?",
"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 row deleted",
+ "{{count}} rows deleted_one": "1行を削除しました",
"{{count}} rows deleted_other": "{{count}} rows deleted",
- "{{count}} selected_one": "1 selected",
+ "{{count}} selected_one": "1件を選択中",
"{{count}} selected_other": "{{count}} selected",
"Compare": "比較",
"Compare versions": "バージョンを比較",
diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json
index 829fa8146..91ec95d3e 100644
--- a/apps/client/public/locales/ko-KR/translation.json
+++ b/apps/client/public/locales/ko-KR/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "MCP에 대해 OAuth 강제 전환",
"Supported tools": "지원되는 도구",
"MCP server URL:": "MCP 서버 URL:",
"Learn more": "자세히 알아보기",
diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json
index 58199c144..b04f2f6fa 100644
--- a/apps/client/public/locales/nl-NL/translation.json
+++ b/apps/client/public/locales/nl-NL/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "OAuth afdwingen",
"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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "OAuth afdwingen voor MCP in- of uitschakelen",
"Supported tools": "Ondersteunde tools",
"MCP server URL:": "MCP-server-URL:",
"Learn more": "Meer informatie",
diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json
index 7aa9c4fae..610309d75 100644
--- a/apps/client/public/locales/pt-BR/translation.json
+++ b/apps/client/public/locales/pt-BR/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "Exigir 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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "Ativar/desativar exigência de OAuth para MCP",
"Supported tools": "Ferramentas compatíveis",
"MCP server URL:": "URL do servidor MCP:",
"Learn more": "Saiba mais",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 6893b33a7..a49ee5d6c 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "Сделать 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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "Переключить обязательное использование OAuth для MCP",
"Supported tools": "Поддерживаемые инструменты",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Подробнее",
diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json
index efb464649..33b646712 100644
--- a/apps/client/public/locales/uk-UA/translation.json
+++ b/apps/client/public/locales/uk-UA/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "Зробити 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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "Увімкнути обов’язковий OAuth для MCP",
"Supported tools": "Підтримувані інструменти",
"MCP server URL:": "URL сервера MCP:",
"Learn more": "Дізнатися більше",
diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json
index 8caa80788..68467a024 100644
--- a/apps/client/public/locales/zh-CN/translation.json
+++ b/apps/client/public/locales/zh-CN/translation.json
@@ -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": "Enforce OAuth",
+ "Enforce OAuth": "强制使用 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": "Toggle enforce OAuth for MCP",
+ "Toggle enforce OAuth for MCP": "切换 MCP 的强制使用 OAuth 设置",
"Supported tools": "支持的工具",
"MCP server URL:": "MCP 服务器 URL:",
"Learn more": "了解更多",
From 5b854645615026d642c5e1735a3eafc59a3211f2 Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Fri, 28 Aug 2026 00:07:10 +0100
Subject: [PATCH 22/27] sync
---
apps/server/src/ee | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 844f2003c..cbf35d363 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 844f2003cdc36268478fdfb5ad64b656df6b633b
+Subproject commit cbf35d363a1745bc09e7e8c72ca1fd63cf1b7b5c
From 0d69d48c524596028f430031bf5ccdfb3710334f Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Fri, 4 Sep 2026 14:53:14 +0100
Subject: [PATCH 23/27] feat(ee): SIEM (#2471)
---
.../public/locales/en-US/translation.json | 64 ++-
apps/client/src/App.tsx | 5 +
.../components/settings/settings-sidebar.tsx | 4 +-
.../src/ee/audit/lib/audit-event-labels.ts | 32 ++
apps/client/src/ee/audit/pages/audit-logs.tsx | 254 ++++++-----
apps/client/src/ee/features.ts | 1 +
.../components/delete-destination-modal.tsx | 44 ++
.../components/destination-form-modal.tsx | 300 +++++++++++++
.../components/destination-status-badge.tsx | 15 +
.../ee/siem/components/destination-table.tsx | 155 +++++++
.../siem/components/siem-streaming-panel.tsx | 129 ++++++
.../src/ee/siem/lib/destination-form.ts | 179 ++++++++
apps/client/src/ee/siem/queries/siem-query.ts | 115 +++++
.../src/ee/siem/services/siem-service.ts | 46 ++
apps/client/src/ee/siem/types/siem.types.ts | 91 ++++
.../components/notification-item.tsx | 33 +-
.../notification/types/notification.types.ts | 5 +-
apps/server/package.json | 5 +-
apps/server/src/app.module.ts | 4 +-
apps/server/src/common/events/audit-events.ts | 17 +-
apps/server/src/common/features.ts | 1 +
apps/server/src/common/helpers/cache-keys.ts | 1 +
.../src/core/auth/services/auth.service.ts | 7 +
.../notification/notification.constants.ts | 6 +
.../20260902T121326-siem-destinations.ts | 66 +++
apps/server/src/database/types/db.d.ts | 26 ++
.../server/src/database/types/entity.types.ts | 6 +
apps/server/src/ee | 2 +-
.../environment/environment.service.ts | 4 +
.../outbound/outbound-agent.factory.spec.ts | 23 +
.../outbound/outbound-agent.factory.ts | 55 +++
.../outbound/outbound-network-policy.spec.ts | 143 ++++++
.../outbound/outbound-network-policy.ts | 110 +++++
.../outbound/outbound-url.guard.spec.ts | 412 ++++++++++++++++++
.../outbound/outbound-url.guard.ts | 231 ++++++++++
.../integrations/outbound/outbound.module.ts | 10 +
.../queue/constants/queue.constants.ts | 4 +
.../src/integrations/queue/queue.module.ts | 8 +
.../integrations/throttle/throttle.module.ts | 2 +
.../integrations/throttle/throttler-names.ts | 2 +
.../siem-destination-disabled-email.tsx | 41 ++
.../emails/siem-destination-failing-email.tsx | 41 ++
.../siem-destination-recovered-email.tsx | 35 ++
43 files changed, 2612 insertions(+), 122 deletions(-)
create mode 100644 apps/client/src/ee/siem/components/delete-destination-modal.tsx
create mode 100644 apps/client/src/ee/siem/components/destination-form-modal.tsx
create mode 100644 apps/client/src/ee/siem/components/destination-status-badge.tsx
create mode 100644 apps/client/src/ee/siem/components/destination-table.tsx
create mode 100644 apps/client/src/ee/siem/components/siem-streaming-panel.tsx
create mode 100644 apps/client/src/ee/siem/lib/destination-form.ts
create mode 100644 apps/client/src/ee/siem/queries/siem-query.ts
create mode 100644 apps/client/src/ee/siem/services/siem-service.ts
create mode 100644 apps/client/src/ee/siem/types/siem.types.ts
create mode 100644 apps/server/src/database/migrations/20260902T121326-siem-destinations.ts
create mode 100644 apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts
create mode 100644 apps/server/src/integrations/outbound/outbound-agent.factory.ts
create mode 100644 apps/server/src/integrations/outbound/outbound-network-policy.spec.ts
create mode 100644 apps/server/src/integrations/outbound/outbound-network-policy.ts
create mode 100644 apps/server/src/integrations/outbound/outbound-url.guard.spec.ts
create mode 100644 apps/server/src/integrations/outbound/outbound-url.guard.ts
create mode 100644 apps/server/src/integrations/outbound/outbound.module.ts
create mode 100644 apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx
create mode 100644 apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx
create mode 100644 apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 3a2736f4b..ef7db5d20 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -793,6 +793,10 @@
"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",
@@ -880,6 +884,9 @@
"
{{name}} returned a page for revision": "
{{name}} returned a page for revision",
"Page verification expires soon": "Page verification expires soon",
"Page verification has expired": "Page verification has expired",
+ "SIEM destination
{{name}} is failing": "SIEM destination
{{name}} is failing",
+ "SIEM destination
{{name}} was disabled after 24 hours of failures": "SIEM destination
{{name}} was disabled after 24 hours of failures",
+ "SIEM destination
{{name}} recovered": "SIEM destination
{{name}} 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.",
@@ -1337,5 +1344,60 @@
"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"
+ "you": "you",
+ "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"
}
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index 4207c3006..1f7967c2c 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -166,6 +166,11 @@ export default function App() {
} />
} />
} />
+
} />
+
}
+ />
} />
{!isCloud() &&
} />}
{isCloud() &&
} />}
diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx
index 542cad910..1a1aec857 100644
--- a/apps/client/src/components/settings/settings-sidebar.tsx
+++ b/apps/client/src/components/settings/settings-sidebar.tsx
@@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [
role: "admin",
},
{
- label: "Audit log",
+ label: "Audit logs & SIEM",
icon: IconHistory,
path: "/settings/audit",
feature: Feature.AUDIT_LOGS,
@@ -219,7 +219,7 @@ export default function SettingsSidebar() {
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
- case "Audit log":
+ case "Audit logs & SIEM":
prefetchHandler = prefetchAuditLogs;
break;
case "Verified pages":
diff --git a/apps/client/src/ee/audit/lib/audit-event-labels.ts b/apps/client/src/ee/audit/lib/audit-event-labels.ts
index 7fc55b3d0..2f22514f0 100644
--- a/apps/client/src/ee/audit/lib/audit-event-labels.ts
+++ b/apps/client/src/ee/audit/lib/audit-event-labels.ts
@@ -22,6 +22,7 @@ export const auditEventLabels: Record
= {
"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",
@@ -62,6 +63,7 @@ export const auditEventLabels: Record = {
"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",
@@ -79,6 +81,13 @@ export const auditEventLabels: Record = {
"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 {
@@ -105,6 +114,10 @@ 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" },
],
@@ -147,6 +160,10 @@ 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" },
@@ -193,4 +210,19 @@ 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" },
+ ],
+ },
];
diff --git a/apps/client/src/ee/audit/pages/audit-logs.tsx b/apps/client/src/ee/audit/pages/audit-logs.tsx
index 811e094e8..cd55ed43b 100644
--- a/apps/client/src/ee/audit/pages/audit-logs.tsx
+++ b/apps/client/src/ee/audit/pages/audit-logs.tsx
@@ -7,10 +7,12 @@ 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";
@@ -23,6 +25,7 @@ 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";
@@ -48,6 +51,8 @@ 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(null);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -85,6 +90,8 @@ export default function AuditLogs() {
const { data, isLoading } = useAuditLogsQuery(params);
+ const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
+
if (!isOwner) {
return null;
}
@@ -94,125 +101,150 @@ export default function AuditLogs() {
resetCursor();
};
+ const handleTabChange = (value: string | null) => {
+ if (value === "siem") {
+ navigate("/settings/audit/siem");
+ } else {
+ navigate("/settings/audit");
+ }
+ };
+
return (
<>
-
+
-
+
-
- ({
- 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"
- />
+
+
+
+ {t("Audit logs")}
+
+
+ {t("SIEM")}
+
+
- {
- if (!opened) resetRetentionForm();
- setSettingsOpen(opened);
- }}
- >
-
-
- setSettingsOpen((o) => !o)}>
-
-
-
-
-
-
- {t("Retention")}
-
-
- {t("Logs older than this period are automatically deleted.")}
-
-
- setRetentionAmount(val)}
- min={1}
- hideControls
- size="sm"
- w={60}
- />
- {
- if (value === "days" || value === "months" || value === "years") {
- setRetentionUnit(value);
- }
- }}
- size="sm"
- style={{ flex: 1 }}
- comboboxProps={{ withinPortal: false }}
- />
-
-
- {
- resetRetentionForm();
- setSettingsOpen(false);
- }}
- >
- {t("Cancel")}
-
- {
- 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")}
-
-
-
-
-
+
+
+ ({
+ 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"
+ />
-
+ {
+ if (!opened) resetRetentionForm();
+ setSettingsOpen(opened);
+ }}
+ >
+
+
+ setSettingsOpen((o) => !o)}>
+
+
+
+
+
+
+ {t("Retention")}
+
+
+ {t("Logs older than this period are automatically deleted.")}
+
+
+ setRetentionAmount(val)}
+ min={1}
+ hideControls
+ size="sm"
+ w={60}
+ />
+ {
+ if (value === "days" || value === "months" || value === "years") {
+ setRetentionUnit(value);
+ }
+ }}
+ size="sm"
+ style={{ flex: 1 }}
+ comboboxProps={{ withinPortal: false }}
+ />
+
+
+ {
+ resetRetentionForm();
+ setSettingsOpen(false);
+ }}
+ >
+ {t("Cancel")}
+
+ {
+ 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")}
+
+
+
+
+
-
+
- {data?.items && data.items.length > 0 && (
- goNext(data?.meta?.nextCursor)}
- onPrev={goPrev}
- />
- )}
+
+
+ {data?.items && data.items.length > 0 && (
+ goNext(data?.meta?.nextCursor)}
+ onPrev={goPrev}
+ />
+ )}
+
+
+
+
+
+
>
);
}
diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts
index 043849b49..a62462459 100644
--- a/apps/client/src/ee/features.ts
+++ b/apps/client/src/ee/features.ts
@@ -25,4 +25,5 @@ export const Feature = {
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
+ SIEM: 'siem',
} as const;
diff --git a/apps/client/src/ee/siem/components/delete-destination-modal.tsx b/apps/client/src/ee/siem/components/delete-destination-modal.tsx
new file mode 100644
index 000000000..80fbc1bb3
--- /dev/null
+++ b/apps/client/src/ee/siem/components/delete-destination-modal.tsx
@@ -0,0 +1,44 @@
+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 (
+
+
+
+ {t("Are you sure you want to delete the destination")}{" "}
+ {destination?.name} ?
+
+
+ {t("Cancel")}
+
+ {t("Delete")}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/siem/components/destination-form-modal.tsx b/apps/client/src/ee/siem/components/destination-form-modal.tsx
new file mode 100644
index 000000000..3fa7cf70e
--- /dev/null
+++ b/apps/client/src/ee/siem/components/destination-form-modal.tsx
@@ -0,0 +1,300 @@
+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({
+ 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 (
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/siem/components/destination-status-badge.tsx b/apps/client/src/ee/siem/components/destination-status-badge.tsx
new file mode 100644
index 000000000..17618aab8
--- /dev/null
+++ b/apps/client/src/ee/siem/components/destination-status-badge.tsx
@@ -0,0 +1,15 @@
+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 {t("Disabled")} ;
+ }
+ if (destination.status === "failing") {
+ return {t("Failing")} ;
+ }
+ return {t("Healthy")} ;
+}
diff --git a/apps/client/src/ee/siem/components/destination-table.tsx b/apps/client/src/ee/siem/components/destination-table.tsx
new file mode 100644
index 000000000..c2d540a43
--- /dev/null
+++ b/apps/client/src/ee/siem/components/destination-table.tsx
@@ -0,0 +1,155 @@
+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 = {
+ 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 (
+
+
+
+
+ {t("Name")}
+ {t("Type")}
+ {t("Enabled")}
+ {t("Status")}
+ {t("Last delivered")}
+ {t("Last error")}
+
+
+
+
+ {destinations && destinations.length > 0 ? (
+ destinations.map((destination) => (
+
+
+ {destination.name}
+
+
+ {DESTINATION_TYPE_LABELS[destination.type]}
+
+
+ onToggle(destination, event.currentTarget.checked)}
+ aria-label={t("Enabled")}
+ />
+
+
+
+ {destination.failingSince &&
+ (destination.status === "failing" ||
+ !destination.enabled) && (
+
+ {t("Failing since {{time}}", {
+ time: formattedDate(
+ new Date(destination.failingSince),
+ ),
+ })}
+
+ )}
+
+
+
+ {destination.lastDeliveredAt
+ ? timeAgo(new Date(destination.lastDeliveredAt))
+ : t("Never")}
+
+
+
+ {destination.lastError ? (
+
+
+ {destination.lastError}
+
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+
+
+
+ } onClick={() => onEdit(destination)}>
+ {t("Edit")}
+
+ } onClick={() => onTest(destination)}>
+ {t("Send test event")}
+
+ }
+ onClick={() => onRetry(destination)}
+ disabled={!destination.nextAttemptAt}
+ >
+ {t("Retry now")}
+
+
+ } onClick={() => onDelete(destination)}>
+ {t("Delete")}
+
+
+
+
+
+ ))
+ ) : (
+ !isLoading && (
+
+
+
+ {t("No destinations yet")}
+
+
+
+ )
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/siem/components/siem-streaming-panel.tsx b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx
new file mode 100644
index 000000000..e54230de3
--- /dev/null
+++ b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx
@@ -0,0 +1,129 @@
+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(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,
+ 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 && (
+ } color="yellow" mb="md">
+ {t("SIEM streaming requires an Enterprise license.")}
+
+ )}
+
+
+
+
+ {
+ setSelected(null);
+ setFormOpened(true);
+ }}
+ disabled={!hasFeature || atDestinationLimit}
+ >
+ {t("Add destination")}
+
+
+
+
+
+ {isError && (
+ } color="red" mb="md">
+ {t("Could not load SIEM destinations: {{message}}", {
+ message: extractErrorMessage(error),
+ })}
+
+ )}
+
+ {hasFeature && !isError && (
+ {
+ 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 })
+ }
+ />
+ )}
+
+ setFormOpened(false)}
+ destination={selected}
+ />
+ setDeleteOpened(false)}
+ destination={selected}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/ee/siem/lib/destination-form.ts b/apps/client/src/ee/siem/lib/destination-form.ts
new file mode 100644
index 000000000..4a52cc4f6
--- /dev/null
+++ b/apps/client/src/ee/siem/lib/destination-form.ts
@@ -0,0 +1,179 @@
+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;
+ 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 = {},
+): Partial> {
+ const errors: Partial> = {};
+
+ 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 {
+ 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;
+ let secrets: Record;
+
+ 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,
+ };
+}
diff --git a/apps/client/src/ee/siem/queries/siem-query.ts b/apps/client/src/ee/siem/queries/siem-query.ts
new file mode 100644
index 000000000..e3f814c04
--- /dev/null
+++ b/apps/client/src/ee/siem/queries/siem-query.ts
@@ -0,0 +1,115 @@
+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 {
+ 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({
+ mutationFn: createSiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Destination created") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useUpdateSiemDestinationMutation() {
+ const { t } = useTranslation();
+ const invalidate = useInvalidateDestinations();
+ return useMutation({
+ mutationFn: updateSiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Destination updated") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useDeleteSiemDestinationMutation() {
+ const { t } = useTranslation();
+ const invalidate = useInvalidateDestinations();
+ return useMutation({
+ mutationFn: deleteSiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Destination deleted") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useRetrySiemDestinationMutation() {
+ const { t } = useTranslation();
+ const invalidate = useInvalidateDestinations();
+ return useMutation({
+ mutationFn: retrySiemDestination,
+ onSuccess: () => {
+ notifications.show({ message: t("Retry scheduled") });
+ invalidate();
+ },
+ onError: showError,
+ });
+}
+
+export function useTestSiemDestinationMutation() {
+ return useMutation({
+ mutationFn: testSiemDestination,
+ onError: showError,
+ });
+}
diff --git a/apps/client/src/ee/siem/services/siem-service.ts b/apps/client/src/ee/siem/services/siem-service.ts
new file mode 100644
index 000000000..0c394ecf3
--- /dev/null
+++ b/apps/client/src/ee/siem/services/siem-service.ts
@@ -0,0 +1,46 @@
+import api from "@/lib/api-client";
+import {
+ ISiemDestination,
+ ISiemDestinationInput,
+ ISiemTestResult,
+ ITestSiemDestinationInput,
+ IUpdateSiemDestinationInput,
+} from "@/ee/siem/types/siem.types";
+
+export async function getSiemDestinations(): Promise {
+ const req = await api.post("/siem/destinations");
+ return req.data;
+}
+
+export async function createSiemDestination(
+ data: ISiemDestinationInput,
+): Promise {
+ const req = await api.post("/siem/destinations/create", data);
+ return req.data;
+}
+
+export async function updateSiemDestination(
+ data: IUpdateSiemDestinationInput,
+): Promise {
+ const req = await api.post("/siem/destinations/update", data);
+ return req.data;
+}
+
+export async function deleteSiemDestination(data: {
+ destinationId: string;
+}): Promise {
+ await api.post("/siem/destinations/delete", data);
+}
+
+export async function testSiemDestination(
+ data: ITestSiemDestinationInput,
+): Promise {
+ const req = await api.post("/siem/destinations/test", data);
+ return req.data;
+}
+
+export async function retrySiemDestination(data: {
+ destinationId: string;
+}): Promise {
+ await api.post("/siem/destinations/retry", data);
+}
diff --git a/apps/client/src/ee/siem/types/siem.types.ts b/apps/client/src/ee/siem/types/siem.types.ts
new file mode 100644
index 000000000..7e8a17232
--- /dev/null
+++ b/apps/client/src/ee/siem/types/siem.types.ts
@@ -0,0 +1,91 @@
+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;
+ 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;
+ secrets?: Record;
+ enabled?: boolean;
+}
+
+export interface IUpdateSiemDestinationInput {
+ destinationId: string;
+ name?: string;
+ config?: Record;
+ secrets?: Record;
+ enabled?: boolean;
+}
+
+export interface ITestSiemDestinationInput {
+ type: SiemDestinationType;
+ config: Record;
+ secrets?: Record;
+ destinationId?: string;
+}
+
+export interface ISiemTestResult {
+ delivered: boolean;
+ error?: string;
+ statusCode?: number;
+}
diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx
index 418647375..12b9f2428 100644
--- a/apps/client/src/features/notification/components/notification-item.tsx
+++ b/apps/client/src/features/notification/components/notification-item.tsx
@@ -63,6 +63,12 @@ export function NotificationItem({
return "Page verification expires soon";
case "page.verification_expired":
return "Page verification has expired";
+ case "siem_destination.failing":
+ return "SIEM destination {{name}} is failing";
+ case "siem_destination.disabled":
+ return "SIEM destination {{name}} was disabled after 24 hours of failures";
+ case "siem_destination.recovered":
+ return "SIEM destination {{name}} recovered";
default:
return "";
}
@@ -77,6 +83,19 @@ 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]);
@@ -97,7 +116,7 @@ export function NotificationItem({
return (
}}
/>
+ {lastError && (
+
+ {lastError}
+
+ )}
+
{notification.page && (
{notification.page.icon ? (
diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts
index 266b9a6e4..e5eb6f658 100644
--- a/apps/client/src/features/notification/types/notification.types.ts
+++ b/apps/client/src/features/notification/types/notification.types.ts
@@ -9,7 +9,10 @@ export type NotificationType =
| "page.verification_expired"
| "page.verified"
| "page.approval_requested"
- | "page.approval_rejected";
+ | "page.approval_rejected"
+ | "siem_destination.failing"
+ | "siem_destination.disabled"
+ | "siem_destination.recovered";
export type INotification = {
id: string;
diff --git a/apps/server/package.json b/apps/server/package.json
index c0a9ec88d..bc5f6543d 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -161,7 +161,8 @@
"moduleFileExtensions": [
"js",
"json",
- "ts"
+ "ts",
+ "tsx"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
@@ -181,7 +182,7 @@
]
}
],
- "^.+\\.(t|j)s$": "ts-jest"
+ "^.+\\.(t|j)sx?$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 2de94a662..5002ca8a7 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -28,6 +28,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
+import { OutboundModule } from './integrations/outbound/outbound.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = [];
@@ -51,7 +52,7 @@ try {
middleware: { mount: true },
}),
LoggerModule,
- NoopAuditModule,
+ ...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]),
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -98,6 +99,7 @@ try {
SecurityModule,
TelemetryModule,
ThrottleModule,
+ OutboundModule,
...enterpriseModules,
],
controllers: [AppController],
diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts
index 24ca2af5d..7d088791b 100644
--- a/apps/server/src/common/events/audit-events.ts
+++ b/apps/server/src/common/events/audit-events.ts
@@ -14,6 +14,7 @@ export const AuditEvent = {
USER_ROLE_CHANGED: 'user.role_changed',
USER_PASSWORD_CHANGED: 'user.password_changed',
USER_PASSWORD_RESET: 'user.password_reset',
+ USER_PASSWORD_RESET_REQUESTED: 'user.password_reset_requested',
USER_UPDATED: 'user.updated',
USER_DEACTIVATED: 'user.deactivated',
USER_ACTIVATED: 'user.activated',
@@ -69,6 +70,7 @@ export const AuditEvent = {
PAGE_RESTRICTION_REMOVED: 'page.restriction_removed',
PAGE_PERMISSION_ADDED: 'page.permission_added',
PAGE_PERMISSION_REMOVED: 'page.permission_removed',
+ PAGE_PERMISSION_ROLE_CHANGED: 'page.permission_role_changed',
// Page verification
PAGE_VERIFICATION_CREATED: 'page.verification_created',
PAGE_VERIFICATION_UPDATED: 'page.verification_updated',
@@ -104,6 +106,16 @@ export const AuditEvent = {
// Attachment
ATTACHMENT_UPLOADED: 'attachment.uploaded',
// ATTACHMENT_DELETED: 'attachment.deleted',
+
+ // SIEM streaming
+ SIEM_DESTINATION_CREATED: 'siem_destination.created',
+ SIEM_DESTINATION_UPDATED: 'siem_destination.updated',
+ SIEM_DESTINATION_DELETED: 'siem_destination.deleted',
+ SIEM_DESTINATION_TEST: 'siem_destination.test',
+
+ // Template
+ TEMPLATE_CREATED: 'template.created',
+ TEMPLATE_DELETED: 'template.deleted',
} as const;
export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent];
@@ -116,7 +128,8 @@ export const EXCLUDED_AUDIT_EVENTS: Set = new Set([
AuditEvent.COMMENT_UPDATED,
AuditEvent.COMMENT_RESOLVED,
AuditEvent.COMMENT_REOPENED,
- AuditEvent.ATTACHMENT_UPLOADED
+ AuditEvent.ATTACHMENT_UPLOADED,
+ AuditEvent.SIEM_DESTINATION_TEST,
]);
export const AuditResource = {
@@ -136,6 +149,8 @@ export const AuditResource = {
WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment',
LICENSE: 'license',
+ SIEM_DESTINATION: 'siem_destination',
+ TEMPLATE: 'template',
} as const;
export type AuditResourceType =
diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts
index fcb21d35a..0afb3ca61 100644
--- a/apps/server/src/common/features.ts
+++ b/apps/server/src/common/features.ts
@@ -26,6 +26,7 @@ export const Feature = {
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
+ SIEM: 'siem',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
diff --git a/apps/server/src/common/helpers/cache-keys.ts b/apps/server/src/common/helpers/cache-keys.ts
index 38b24d20e..394ab173f 100644
--- a/apps/server/src/common/helpers/cache-keys.ts
+++ b/apps/server/src/common/helpers/cache-keys.ts
@@ -4,6 +4,7 @@ export const CacheKey = {
`perm:space-roles:${userId}:${spaceId}`,
PAGE_CAN_EDIT: (userId: string, pageId: string) =>
`perm:can-edit:${userId}:${pageId}`,
+ SIEM_LICENSED: (workspaceId: string) => `siem:licensed:${workspaceId}`,
};
// Permission caches dedupe repeated checks within and across short request bursts.
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index 45148931e..303245045 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -219,6 +219,13 @@ export class AuthService {
subject: 'Reset your password',
template: emailTemplate,
});
+
+ this.auditService.log({
+ event: AuditEvent.USER_PASSWORD_RESET_REQUESTED,
+ resourceType: AuditResource.USER,
+ resourceId: user.id,
+ metadata: { source: 'forgot_password' },
+ });
}
async passwordReset(
diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts
index fc42bc64d..894b98e4c 100644
--- a/apps/server/src/core/notification/notification.constants.ts
+++ b/apps/server/src/core/notification/notification.constants.ts
@@ -10,6 +10,9 @@ export const NotificationType = {
PAGE_VERIFIED: 'page.verified',
PAGE_APPROVAL_REQUESTED: 'page.approval_requested',
PAGE_APPROVAL_REJECTED: 'page.approval_rejected',
+ SIEM_DESTINATION_FAILING: 'siem_destination.failing',
+ SIEM_DESTINATION_DISABLED: 'siem_destination.disabled',
+ SIEM_DESTINATION_RECOVERED: 'siem_destination.recovered',
} as const;
export type NotificationType =
@@ -40,6 +43,9 @@ export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
NotificationType.COMMENT_RESOLVED,
NotificationType.PAGE_USER_MENTION,
NotificationType.PAGE_PERMISSION_GRANTED,
+ NotificationType.SIEM_DESTINATION_FAILING,
+ NotificationType.SIEM_DESTINATION_DISABLED,
+ NotificationType.SIEM_DESTINATION_RECOVERED,
];
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
diff --git a/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts
new file mode 100644
index 000000000..7b76cacea
--- /dev/null
+++ b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts
@@ -0,0 +1,66 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('siem_destinations')
+ .ifNotExists()
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('name', 'varchar', (col) => col.notNull())
+ .addColumn('type', 'varchar', (col) => col.notNull())
+ .addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(true))
+ .addColumn('config', 'jsonb', (col) => col.notNull())
+ .addColumn('secrets', 'text', (col) => col.notNull())
+ .addColumn('cursor_created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('cursor_id', 'uuid', (col) =>
+ col.notNull().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('cursor_snapshot', 'text')
+ // Fences cursor writes from stale jobs after configuration changes.
+ .addColumn('version', 'integer', (col) => col.notNull().defaultTo(0))
+ .addColumn('status', 'varchar', (col) => col.notNull().defaultTo('healthy'))
+ .addColumn('consecutive_failures', 'integer', (col) =>
+ col.notNull().defaultTo(0),
+ )
+ .addColumn('next_attempt_at', 'timestamptz')
+ .addColumn('last_delivered_at', 'timestamptz')
+ .addColumn('last_error', 'text')
+ .addColumn('last_error_at', 'timestamptz')
+ .addColumn('failing_since', 'timestamptz')
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.references('users.id').onDelete('set null'),
+ )
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_siem_destinations_workspace_id')
+ .ifNotExists()
+ .on('siem_destinations')
+ .columns(['workspace_id'])
+ .execute();
+
+ await sql`
+ CREATE INDEX IF NOT EXISTS idx_siem_destinations_due
+ ON siem_destinations (next_attempt_at)
+ WHERE enabled = true
+ `.execute(db);
+
+ await db.schema.alterTable('audit').addColumn('user_agent', 'text').execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.alterTable('audit').dropColumn('user_agent').execute();
+ await db.schema.dropTable('siem_destinations').ifExists().execute();
+}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 25060c71a..373eadbf5 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -74,6 +74,7 @@ export interface Audit {
resourceId: string | null;
resourceType: string;
spaceId: string | null;
+ userAgent: string | null;
workspaceId: string;
}
@@ -361,6 +362,30 @@ export interface SpaceMembers {
userId: string | null;
}
+export interface SiemDestinations {
+ config: Json;
+ consecutiveFailures: Generated;
+ createdAt: Generated;
+ creatorId: string | null;
+ cursorCreatedAt: Generated;
+ cursorId: Generated;
+ cursorSnapshot: string | null;
+ enabled: Generated;
+ failingSince: Timestamp | null;
+ id: Generated;
+ lastDeliveredAt: Timestamp | null;
+ lastError: string | null;
+ lastErrorAt: Timestamp | null;
+ name: string;
+ nextAttemptAt: Timestamp | null;
+ secrets: string;
+ status: Generated;
+ type: string;
+ updatedAt: Generated;
+ version: Generated;
+ workspaceId: string;
+}
+
export interface Spaces {
createdAt: Generated;
creatorId: string | null;
@@ -724,6 +749,7 @@ export interface DB {
pages: Pages;
scimTokens: ScimTokens;
shares: Shares;
+ siemDestinations: SiemDestinations;
spaceMembers: SpaceMembers;
spaces: Spaces;
templates: Templates;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index c7d2af0a0..6fd09a963 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -37,6 +37,7 @@ import {
UserSessions,
ApiKeys,
ScimTokens,
+ SiemDestinations,
Watchers,
Audit as _Audit,
Templates,
@@ -267,3 +268,8 @@ export type UpdatableBaseRow = Updateable>;
export type BaseView = Selectable;
export type InsertableBaseView = Insertable;
export type UpdatableBaseView = Updateable>;
+
+// SIEM destinations
+export type SiemDestination = Selectable;
+export type InsertableSiemDestination = Insertable;
+export type UpdatableSiemDestination = Updateable>;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index cbf35d363..c8ca1467d 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit cbf35d363a1745bc09e7e8c72ca1fd63cf1b7b5c
+Subproject commit c8ca1467dfdde0499b6a6fc21c3425d648ba6a2e
diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts
index c483df626..bb11eafe2 100644
--- a/apps/server/src/integrations/environment/environment.service.ts
+++ b/apps/server/src/integrations/environment/environment.service.ts
@@ -385,4 +385,8 @@ export class EnvironmentService {
.map((o) => o.trim())
.filter(Boolean);
}
+
+ getAllowedPrivateNetworks(): string {
+ return this.configService.get('ALLOWED_PRIVATE_NETWORKS', 'none');
+ }
}
diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts
new file mode 100644
index 000000000..6efcd645e
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts
@@ -0,0 +1,23 @@
+import { Agent } from 'undici';
+import { OutboundAgentFactory } from './outbound-agent.factory';
+import { OutboundUrlError } from './outbound-url.guard';
+
+describe('OutboundAgentFactory', () => {
+ it('validates the URL through the guard and returns a releasable undici Agent', async () => {
+ const validate = jest.fn().mockResolvedValue({ hostname: 'siem.example.com', address: '203.0.113.5', family: 4 });
+ const factory = new OutboundAgentFactory({ validate } as any);
+
+ const lease = await factory.lease('https://siem.example.com/ingest', { caCert: undefined, rejectUnauthorized: true });
+
+ expect(validate).toHaveBeenCalledWith('https://siem.example.com/ingest');
+ expect(lease.dispatcher).toBeInstanceOf(Agent);
+ await expect(lease.release()).resolves.toBeUndefined();
+ });
+
+ it('propagates guard rejections', async () => {
+ const validate = jest.fn().mockRejectedValue(new OutboundUrlError('Destination URL must use https'));
+ const factory = new OutboundAgentFactory({ validate } as any);
+
+ await expect(factory.lease('http://siem.example.com')).rejects.toThrow(OutboundUrlError);
+ });
+});
diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.ts
new file mode 100644
index 000000000..d4ac5b471
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-agent.factory.ts
@@ -0,0 +1,55 @@
+import { Injectable } from '@nestjs/common';
+import { Agent, Dispatcher } from 'undici';
+import { OutboundUrlGuard } from './outbound-url.guard';
+
+export const OUTBOUND_REQUEST_TIMEOUT_MS = 10_000;
+
+export type OutboundTlsOptions = {
+ caCert?: string; // PEM encoded
+ rejectUnauthorized?: boolean; // Defaults to true; self-hosted only when false.
+};
+
+export type AgentLease = {
+ dispatcher: Dispatcher;
+ release: () => Promise;
+};
+
+export type IOutboundAgentFactory = {
+ lease(url: string, tls?: OutboundTlsOptions): Promise;
+};
+
+/** Creates a per-request agent pinned to the address validated by the SSRF guard. */
+@Injectable()
+export class OutboundAgentFactory implements IOutboundAgentFactory {
+ constructor(private readonly urlGuard: OutboundUrlGuard) {}
+
+ async lease(url: string, tls?: OutboundTlsOptions): Promise {
+ const pinned = await this.urlGuard.validate(url);
+
+ const lookup = (_hostname: string, options: any, callback: any) => {
+ if (options?.all) {
+ callback(null, [{ address: pinned.address, family: pinned.family }]);
+ } else {
+ callback(null, pinned.address, pinned.family);
+ }
+ };
+
+ const agent = new Agent({
+ connect: {
+ ca: tls?.caCert || undefined,
+ rejectUnauthorized: tls?.rejectUnauthorized ?? true,
+ lookup: lookup as any,
+ timeout: OUTBOUND_REQUEST_TIMEOUT_MS,
+ },
+ headersTimeout: OUTBOUND_REQUEST_TIMEOUT_MS,
+ bodyTimeout: OUTBOUND_REQUEST_TIMEOUT_MS,
+ });
+
+ return {
+ dispatcher: agent,
+ release: async () => {
+ await agent.close();
+ },
+ };
+ }
+}
diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts
new file mode 100644
index 000000000..b6f8399c8
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts
@@ -0,0 +1,143 @@
+import {
+ parseOutboundNetworkPolicy,
+ policyNamesAddress,
+} from './outbound-network-policy';
+
+describe('parseOutboundNetworkPolicy', () => {
+ it('parses a bare mode', () => {
+ expect(parseOutboundNetworkPolicy('all')).toMatchObject({
+ mode: 'all',
+ entries: [],
+ invalid: false,
+ });
+ expect(parseOutboundNetworkPolicy('none')).toMatchObject({
+ mode: 'none',
+ entries: [],
+ invalid: false,
+ });
+ });
+
+ it('treats an empty value as none with no entries', () => {
+ for (const raw of ['', ' ', ',,']) {
+ expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
+ mode: 'none',
+ entries: [],
+ invalid: false,
+ });
+ }
+ });
+
+ it('ignores case and surrounding whitespace on the mode', () => {
+ expect(parseOutboundNetworkPolicy(' ALL ')).toMatchObject({
+ mode: 'all',
+ invalid: false,
+ });
+ });
+
+ it('parses a mode followed by entries', () => {
+ const policy = parseOutboundNetworkPolicy('all,127.0.0.0/8,::1/128');
+
+ expect(policy.mode).toBe('all');
+ expect(policy.entries).toHaveLength(2);
+ expect(policyNamesAddress(policy, '127.0.0.1', 80)).toBe(true);
+ expect(policyNamesAddress(policy, '127.0.0.1', 8088)).toBe(true);
+ expect(policyNamesAddress(policy, '::1', 443)).toBe(true);
+ expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(false);
+ });
+
+ it('parses an entry with a port and matches only that port', () => {
+ const policy = parseOutboundNetworkPolicy('none,192.168.1.20/32:8088');
+
+ expect(policy.mode).toBe('none');
+ expect(policyNamesAddress(policy, '192.168.1.20', 8088)).toBe(true);
+ expect(policyNamesAddress(policy, '192.168.1.20', 443)).toBe(false);
+ expect(policyNamesAddress(policy, '192.168.1.21', 8088)).toBe(false);
+ });
+
+ it('parses a bracketed IPv6 entry with a port', () => {
+ const policy = parseOutboundNetworkPolicy('[::1/128]:8088');
+
+ expect(policy.mode).toBe('none');
+ expect(policyNamesAddress(policy, '::1', 8088)).toBe(true);
+ expect(policyNamesAddress(policy, '::1', 80)).toBe(false);
+ });
+
+ it('treats entries without a mode as none plus those entries', () => {
+ const policy = parseOutboundNetworkPolicy('10.0.0.0/8');
+
+ expect(policy.mode).toBe('none');
+ expect(policy.invalid).toBe(false);
+ expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(true);
+ expect(policyNamesAddress(policy, '192.168.1.1', 443)).toBe(false);
+ });
+
+ it.each([
+ 'not-a-cidr',
+ 'all,not-a-cidr',
+ 'all,10.0.0.0/8,nonsense',
+ '10.0.0.0/33',
+ '10.0.0.0',
+ '::1/129',
+ '::1/128:8088',
+ '10.0.0.0/8:0',
+ '10.0.0.0/8:70000',
+ '[::1/128]:notaport',
+ '0.0.0.0/0',
+ '::/0',
+ 'all,0.0.0.0/0',
+ '[::/0]:8088',
+ '192.168.1.20/24',
+ '10.1.0.0/8',
+ '172.16.0.1/12',
+ 'fc00::1/7',
+ '[::1/127]',
+ '2001:db8::1/32:8088',
+ ])('fails closed on %s', (raw) => {
+ expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
+ mode: 'none',
+ entries: [],
+ invalid: true,
+ });
+ });
+
+ it.each([
+ '10.0.0.0/8',
+ '172.16.0.0/12',
+ '100.64.0.0/10',
+ '192.168.1.20/32',
+ 'fc00::/7',
+ 'fe80::/10',
+ '::1/128',
+ ])('accepts %s, whose address sits on its prefix boundary', (raw) => {
+ expect(parseOutboundNetworkPolicy(raw)).toMatchObject({
+ entries: [expect.anything()],
+ invalid: false,
+ });
+ });
+
+ it('accepts a bracketed IPv6 entry without a port as the unbracketed form', () => {
+ const bracketed = parseOutboundNetworkPolicy('[::1/128]');
+ const bare = parseOutboundNetworkPolicy('::1/128');
+
+ expect(bracketed).toMatchObject({ mode: 'none', invalid: false });
+ for (const port of [80, 443, 8088]) {
+ expect(policyNamesAddress(bracketed, '::1', port)).toBe(
+ policyNamesAddress(bare, '::1', port),
+ );
+ expect(policyNamesAddress(bracketed, '::1', port)).toBe(true);
+ }
+ });
+
+ it('never names an address when the value is unparseable or the address is not an IP', () => {
+ const policy = parseOutboundNetworkPolicy('all,10.0.0.0/8');
+
+ expect(policyNamesAddress(policy, 'siem.internal', 443)).toBe(false);
+ expect(policyNamesAddress(parseOutboundNetworkPolicy('garbage'), '10.1.2.3', 443)).toBe(false);
+ });
+
+ it('matches an IPv4-mapped IPv6 address against an IPv4 entry', () => {
+ const policy = parseOutboundNetworkPolicy('127.0.0.0/8');
+
+ expect(policyNamesAddress(policy, '::ffff:127.0.0.1', 80)).toBe(true);
+ });
+});
diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.ts b/apps/server/src/integrations/outbound/outbound-network-policy.ts
new file mode 100644
index 000000000..11b816530
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-network-policy.ts
@@ -0,0 +1,110 @@
+import { BlockList, isIPv4, isIPv6 } from 'node:net';
+
+export type OutboundPolicyMode = 'all' | 'none';
+
+export type OutboundPolicyEntry = { list: BlockList; port?: number };
+
+/** An invalid policy denies all private destinations. */
+export type OutboundNetworkPolicy = {
+ mode: OutboundPolicyMode;
+ entries: OutboundPolicyEntry[];
+ invalid: boolean;
+};
+
+function toBytes(address: string, family: 'ipv4' | 'ipv6'): number[] {
+ if (family === 'ipv4') return address.split('.').map(Number);
+
+ const bytesOf = (part: string): number[] =>
+ part
+ ? part.split(':').flatMap((group) => {
+ if (group.includes('.')) return group.split('.').map(Number);
+ const value = parseInt(group, 16);
+ return [value >> 8, value & 0xff];
+ })
+ : [];
+
+ const [head, tail] = address.split('::');
+ const headBytes = bytesOf(head);
+ const tailBytes = address.includes('::') ? bytesOf(tail) : [];
+ const zeros = new Array(16 - headBytes.length - tailBytes.length).fill(0);
+ return [...headBytes, ...zeros, ...tailBytes];
+}
+
+function hasHostBits(bytes: number[], prefix: number): boolean {
+ return bytes.some((byte, index) => {
+ const bitsBefore = index * 8;
+ if (bitsBefore >= prefix) return byte !== 0;
+ return (byte & (0xff >> Math.min(8, prefix - bitsBefore))) !== 0;
+ });
+}
+
+/** Prefix zero is reserved for the explicit `all` mode. */
+function parseCidr(
+ raw: string,
+): { address: string; prefix: number; family: 'ipv4' | 'ipv6' } | null {
+ const [address, prefixRaw] = raw.split('/');
+ if (!prefixRaw) return null;
+ const prefix = Number(prefixRaw);
+ if (!Number.isInteger(prefix) || prefix < 1) return null;
+ const family = isIPv4(address) ? 'ipv4' : isIPv6(address) ? 'ipv6' : null;
+ if (!family) return null;
+ if (prefix > (family === 'ipv4' ? 32 : 128)) return null;
+ if (hasHostBits(toBytes(address, family), prefix)) return null;
+ return { address, prefix, family };
+}
+
+/** Parses optional ports without treating IPv6 colons as separators. */
+function splitPort(token: string): { cidr: string; port?: number } {
+ const bracketed = /^\[(.+)\](?::(\d+))?$/.exec(token);
+ if (bracketed) {
+ const [, cidr, port] = bracketed;
+ return port === undefined ? { cidr } : { cidr, port: Number(port) };
+ }
+ const withPort = /^([^:]+):(\d+)$/.exec(token);
+ if (withPort) return { cidr: withPort[1], port: Number(withPort[2]) };
+ return { cidr: token };
+}
+
+function parseEntry(token: string): OutboundPolicyEntry | null {
+ const { cidr: raw, port } = splitPort(token);
+ if (port !== undefined && (port < 1 || port > 65535)) return null;
+ const cidr = parseCidr(raw);
+ if (!cidr) return null;
+ const list = new BlockList();
+ list.addSubnet(cidr.address, cidr.prefix, cidr.family);
+ return { list, port };
+}
+
+/** Parses `[all|none,]CIDR[:port],...` and fails closed on invalid input. */
+export function parseOutboundNetworkPolicy(raw: string): OutboundNetworkPolicy {
+ const tokens = (raw ?? '')
+ .split(',')
+ .map((token) => token.trim())
+ .filter(Boolean);
+ if (tokens.length === 0) return { mode: 'none', entries: [], invalid: false };
+
+ const first = tokens[0].toLowerCase();
+ const hasMode = first === 'all' || first === 'none';
+ const mode: OutboundPolicyMode = hasMode ? first : 'none';
+
+ const entries: OutboundPolicyEntry[] = [];
+ for (const token of hasMode ? tokens.slice(1) : tokens) {
+ const entry = parseEntry(token);
+ if (!entry) return { mode: 'none', entries: [], invalid: true };
+ entries.push(entry);
+ }
+ return { mode, entries, invalid: false };
+}
+
+export function policyNamesAddress(
+ policy: OutboundNetworkPolicy,
+ ip: string,
+ port: number,
+): boolean {
+ const family = isIPv4(ip) ? 'ipv4' : isIPv6(ip) ? 'ipv6' : null;
+ if (!family) return false;
+ return policy.entries.some(
+ (entry) =>
+ (entry.port === undefined || entry.port === port) && entry.list.check(ip, family),
+ );
+}
diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts
new file mode 100644
index 000000000..e480a0928
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts
@@ -0,0 +1,412 @@
+import { Logger } from '@nestjs/common';
+import {
+ isAlwaysBlockedAddress,
+ isHardBlockedAddress,
+ isPrivateAddress,
+ isPrivateNetworkAddress,
+ OutboundUrlError,
+ OutboundUrlGuard,
+} from './outbound-url.guard';
+
+function guard(
+ isCloud: boolean,
+ addresses: Array<{ address: string; family: number }>,
+ privateNetworks: string = 'none',
+) {
+ return new OutboundUrlGuard(
+ {
+ isCloud: () => isCloud,
+ getAllowedPrivateNetworks: () => privateNetworks,
+ } as any,
+ async () => addresses,
+ );
+}
+
+function family(ip: string): number {
+ return ip.includes(':') ? 6 : 4;
+}
+
+describe('isPrivateAddress', () => {
+ it.each([
+ '127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1',
+ '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1',
+ '::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:10.0.0.1',
+ '0:0:0:0:0:0:0:1', '::ffff:a00:1', '::ffff:7f00:1', '0000:0000:0000:0000:0000:0000:0000:0000',
+ '192.0.0.1', '192.0.2.1', '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5',
+ '::a00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1', '100::1', '2001::1', '2001:0:a00:1::1', '2001:db8::1', '2002:a00:1::1', 'fec0::1',
+ ])('flags %s as private or reserved', (ip) => {
+ expect(isPrivateAddress(ip)).toBe(true);
+ });
+
+ it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '::ffff:8.8.8.8', '::ffff:5db8:d822', '::ffff:8.8.8.8', '2001:4860:4860::8888', '100.128.0.1', '198.17.255.255'])(
+ 'allows public %s',
+ (ip) => {
+ expect(isPrivateAddress(ip)).toBe(false);
+ },
+ );
+});
+
+describe('isAlwaysBlockedAddress / isPrivateNetworkAddress', () => {
+ it.each([
+ '0.0.0.0', '127.0.0.1', '169.254.169.254', '192.0.0.1', '192.0.2.1',
+ '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5', '224.0.0.1',
+ '::1', '::', '::ffff:127.0.0.1', '::ffff:0:7f00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1',
+ '100::1', '2001::1', '2001:db8::1', '2002:a00:1::1', 'fe80::1', 'fec0::1',
+ 'ff02::1',
+ ])('flags %s as always-blocked but not a private network', (ip) => {
+ expect(isAlwaysBlockedAddress(ip)).toBe(true);
+ expect(isPrivateNetworkAddress(ip)).toBe(false);
+ });
+
+ it.each([
+ '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '100.64.0.1',
+ 'fc00::1', 'fd12::1',
+ ])('flags %s as a private network but not always-blocked', (ip) => {
+ expect(isPrivateNetworkAddress(ip)).toBe(true);
+ expect(isAlwaysBlockedAddress(ip)).toBe(false);
+ });
+
+ it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '100.128.0.1'])(
+ 'allows public %s in both',
+ (ip) => {
+ expect(isAlwaysBlockedAddress(ip)).toBe(false);
+ expect(isPrivateNetworkAddress(ip)).toBe(false);
+ },
+ );
+
+ it('the two lists together are exactly isPrivateAddress', () => {
+ for (const ip of ['10.0.0.5', '127.0.0.1', '8.8.8.8', 'fe80::1', 'fc00::1']) {
+ expect(isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip)).toBe(
+ isPrivateAddress(ip),
+ );
+ }
+ });
+});
+
+describe('OutboundUrlGuard.validate', () => {
+ const publicV4 = { address: '93.184.216.34', family: 4 };
+
+ it('rejects http on cloud', async () => {
+ await expect(guard(true, [publicV4]).validate('http://siem.example.com/x'))
+ .rejects.toThrow(OutboundUrlError);
+ });
+
+ it('rejects hosts that resolve to a private range on cloud', async () => {
+ await expect(
+ guard(true, [publicV4, { address: '10.0.0.5', family: 4 }]).validate('https://siem.example.com'),
+ ).rejects.toThrow(/private or reserved/);
+ });
+
+ it('rejects the cloud metadata address literal', async () => {
+ await expect(guard(true, []).validate('https://169.254.169.254/latest'))
+ .rejects.toThrow(/private or reserved/);
+ });
+
+ it('allows LAN hosts and http on self-hosted when private networks are allowed', async () => {
+ const pinned = await guard(false, [{ address: '10.0.5.20', family: 4 }], 'all')
+ .validate('http://splunk.internal:8088/services/collector/event');
+ expect(pinned).toEqual({ hostname: 'splunk.internal', address: '10.0.5.20', family: 4 });
+ });
+
+ it('pins the first resolved address and keeps the hostname for SNI', async () => {
+ const pinned = await guard(true, [{ address: '2606:4700::1111', family: 6 }, publicV4])
+ .validate('https://siem.example.com');
+ expect(pinned).toEqual({ hostname: 'siem.example.com', address: '2606:4700::1111', family: 6 });
+ });
+
+ it('rejects credentials in the URL and unresolvable hosts', async () => {
+ await expect(guard(false, [publicV4]).validate('https://user:pw@siem.example.com'))
+ .rejects.toThrow(/credentials/);
+ await expect(guard(false, []).validate('https://nope.example.com'))
+ .rejects.toThrow(/Could not resolve/);
+ });
+
+ it('marks resolution failures retryable and configuration failures not', async () => {
+ const throwing = new OutboundUrlGuard(
+ { isCloud: () => false } as any,
+ async () => {
+ throw new Error('EAI_AGAIN');
+ },
+ );
+
+ const dnsError = await throwing
+ .validate('https://siem.example.com')
+ .catch((e) => e);
+ expect(dnsError).toBeInstanceOf(OutboundUrlError);
+ expect(dnsError.retryable).toBe(true);
+
+ const emptyError = await guard(false, [])
+ .validate('https://nope.example.com')
+ .catch((e) => e);
+ expect(emptyError.retryable).toBe(true);
+
+ for (const url of [
+ 'not-a-url',
+ 'ftp://siem.example.com',
+ 'https://user:pw@siem.example.com',
+ ]) {
+ const err = await guard(false, [publicV4])
+ .validate(url)
+ .catch((e) => e);
+ expect(err).toBeInstanceOf(OutboundUrlError);
+ expect(err.retryable).toBe(false);
+ }
+
+ const privateError = await guard(true, [{ address: '10.0.0.5', family: 4 }])
+ .validate('https://siem.example.com')
+ .catch((e) => e);
+ expect(privateError.retryable).toBe(false);
+ });
+
+ const hardBlocked = ['169.254.169.254', '0.0.0.0', 'fe80::1', 'ff02::1'];
+ const loopbackOrReserved = ['127.0.0.1', '::1', '::ffff:127.0.0.1', '192.0.2.1'];
+
+ it.each(hardBlocked)(
+ 'self-hosted refuses %s under every ALLOWED_PRIVATE_NETWORKS value',
+ async (ip) => {
+ for (const value of ['all', 'none', '169.254.0.0/16', 'all,169.254.0.0/16', 'all,fe80::/10']) {
+ await expect(
+ guard(false, [{ address: ip, family: family(ip) }], value).validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/link-local, metadata or reserved address .* which is never allowed/);
+ }
+ },
+ );
+
+ it.each(loopbackOrReserved)(
+ 'self-hosted refuses %s unless an entry names it',
+ async (ip) => {
+ for (const value of ['all', 'none']) {
+ await expect(
+ guard(false, [{ address: ip, family: family(ip) }], value).validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(
+ /resolves to a loopback or reserved address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/,
+ );
+ }
+ },
+ );
+
+ it.each(['10.1.2.3', '192.168.1.10'])(
+ 'self-hosted refuses private network %s by default',
+ async (ip) => {
+ await expect(
+ guard(false, [{ address: ip, family: 4 }]).validate('http://siem.internal'),
+ ).rejects.toThrow(
+ /resolves to a private address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/,
+ );
+ },
+ );
+
+ it.each(['10.1.2.3', '192.168.1.10', 'fc00::1', '100.64.0.1'])(
+ 'all accepts private network %s',
+ async (ip) => {
+ const pinned = await guard(
+ false,
+ [{ address: ip, family: family(ip) }],
+ 'all',
+ ).validate('http://siem.internal');
+ expect(pinned.address).toBe(ip);
+ },
+ );
+
+ it('all still refuses loopback, and a loopback entry opts it back in', async () => {
+ await expect(
+ guard(false, [{ address: '127.0.0.1', family: 4 }], 'all').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/loopback or reserved/);
+
+ const allowed = await guard(
+ false,
+ [{ address: '127.0.0.1', family: 4 }],
+ 'all,127.0.0.0/8',
+ ).validate('http://siem.internal');
+ expect(allowed.address).toBe('127.0.0.1');
+
+ const lan = await guard(
+ false,
+ [{ address: '10.1.2.3', family: 4 }],
+ 'all,127.0.0.0/8',
+ ).validate('http://siem.internal');
+ expect(lan.address).toBe('10.1.2.3');
+
+ await expect(
+ guard(false, [{ address: '::1', family: 6 }], 'all,127.0.0.0/8').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/loopback or reserved/);
+ });
+
+ it('an entry with a port matches only that port', async () => {
+ const policy = 'none,192.168.1.20/32:8088';
+
+ const allowed = await guard(
+ false,
+ [{ address: '192.168.1.20', family: 4 }],
+ policy,
+ ).validate('https://192.168.1.20:8088/services/collector/event');
+ expect(allowed.address).toBe('192.168.1.20');
+
+ await expect(
+ guard(false, [{ address: '192.168.1.20', family: 4 }], policy).validate(
+ 'https://192.168.1.20',
+ ),
+ ).rejects.toThrow(/private address/);
+
+ await expect(
+ guard(false, [{ address: '192.168.1.21', family: 4 }], policy).validate(
+ 'https://192.168.1.21:8088',
+ ),
+ ).rejects.toThrow(/private address/);
+ });
+
+ it('a bracketed IPv6 entry with a port accepts only that port', async () => {
+ const policy = '[::1/128]:8088';
+
+ const allowed = await guard(
+ false,
+ [{ address: '::1', family: 6 }],
+ policy,
+ ).validate('http://[::1]:8088/ingest');
+ expect(allowed).toEqual({ hostname: '::1', address: '::1', family: 6 });
+
+ await expect(
+ guard(false, [{ address: '::1', family: 6 }], policy).validate('http://[::1]/ingest'),
+ ).rejects.toThrow(/loopback or reserved/);
+ });
+
+ it('an entry without a port matches every port', async () => {
+ for (const url of ['http://127.0.0.1:8088', 'https://127.0.0.1', 'http://127.0.0.1']) {
+ const allowed = await guard(
+ false,
+ [{ address: '127.0.0.1', family: 4 }],
+ '127.0.0.0/8',
+ ).validate(url);
+ expect(allowed.address).toBe('127.0.0.1');
+ }
+ });
+
+ it('entries without a mode none every private network not named', async () => {
+ const allowed = await guard(
+ false,
+ [{ address: '192.168.1.10', family: 4 }],
+ '192.168.1.0/24',
+ ).validate('http://siem.internal');
+ expect(allowed.address).toBe('192.168.1.10');
+
+ await expect(
+ guard(false, [{ address: '10.1.2.3', family: 4 }], '192.168.1.0/24').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/private address/);
+ });
+
+ it('an unparseable value denies everything private or reserved and logs once per process', async () => {
+ const errorSpy = jest
+ .spyOn(Logger.prototype, 'error')
+ .mockImplementation(() => undefined);
+ const g = guard(false, [{ address: '10.1.2.3', family: 4 }], 'all,10.0.0.0/8, not-a-cidr');
+
+ await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/);
+ await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/);
+
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ expect(errorSpy.mock.calls[0][0]).toMatch(/ALLOWED_PRIVATE_NETWORKS/);
+ errorSpy.mockRestore();
+ });
+
+ it('cloud ignores ALLOWED_PRIVATE_NETWORKS and always refuses private and reserved ranges', async () => {
+ for (const ip of [...hardBlocked, ...loopbackOrReserved, '10.1.2.3', '192.168.1.10']) {
+ for (const value of ['all', 'none', '127.0.0.0/8', 'all,10.0.0.0/8']) {
+ await expect(
+ guard(true, [{ address: ip, family: family(ip) }], value).validate(
+ 'https://siem.example.com',
+ ),
+ ).rejects.toThrow(/private or reserved/);
+ }
+ }
+ });
+
+ it('refuses a resolved address that is not an IP address', async () => {
+ await expect(
+ guard(false, [{ address: 'not-an-ip', family: 4 }], 'all').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/is not an IP address/);
+ });
+
+ it('refuses the whole host when any one of its addresses is refused', async () => {
+ await expect(
+ guard(
+ false,
+ [publicV4, { address: '10.1.2.3', family: 4 }],
+ 'none',
+ ).validate('http://siem.internal'),
+ ).rejects.toThrow(/private address/);
+
+ await expect(
+ guard(
+ false,
+ [publicV4, { address: '127.0.0.1', family: 4 }],
+ 'all',
+ ).validate('http://siem.internal'),
+ ).rejects.toThrow(/loopback or reserved/);
+
+ await expect(
+ guard(
+ false,
+ [publicV4, { address: '169.254.169.254', family: 4 }],
+ 'all',
+ ).validate('http://siem.internal'),
+ ).rejects.toThrow(/never allowed/);
+ });
+
+ it('refuses an IPv4-translated loopback address even when private networks are allowed', async () => {
+ await expect(
+ guard(false, [{ address: '::ffff:0:7f00:1', family: 6 }], 'all').validate(
+ 'http://siem.internal',
+ ),
+ ).rejects.toThrow(/loopback or reserved/);
+ });
+
+ it('a public address is allowed in every mode', async () => {
+ const errorSpy = jest
+ .spyOn(Logger.prototype, 'error')
+ .mockImplementation(() => undefined);
+
+ for (const value of ['all', 'none', '', '192.168.1.0/24', 'garbage']) {
+ await expect(
+ guard(false, [publicV4], value).validate('http://siem.example.com'),
+ ).resolves.toMatchObject({ address: publicV4.address });
+ }
+ await expect(
+ guard(true, [publicV4], 'all').validate('https://siem.example.com'),
+ ).resolves.toMatchObject({ address: publicV4.address });
+ errorSpy.mockRestore();
+ });
+});
+
+describe('isHardBlockedAddress', () => {
+ it.each([
+ '0.0.0.0', '169.254.169.254', '224.0.0.1', '255.255.255.255',
+ '::', 'fe80::1', 'ff02::1',
+ ])('flags %s as hard-blocked', (ip) => {
+ expect(isHardBlockedAddress(ip)).toBe(true);
+ });
+
+ it.each(['127.0.0.1', '::1', '192.0.2.1', 'fec0::1', '8.8.8.8'])(
+ 'does not flag %s as hard-blocked (it may still be always-blocked)',
+ (ip) => {
+ expect(isHardBlockedAddress(ip)).toBe(false);
+ },
+ );
+
+ it('is a subset of isAlwaysBlockedAddress', () => {
+ for (const ip of ['0.0.0.0', '169.254.169.254', 'fe80::1', 'ff02::1']) {
+ expect(isAlwaysBlockedAddress(ip)).toBe(true);
+ }
+ });
+});
diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.ts b/apps/server/src/integrations/outbound/outbound-url.guard.ts
new file mode 100644
index 000000000..1c9e4a872
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound-url.guard.ts
@@ -0,0 +1,231 @@
+import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
+import { promises as dns } from 'node:dns';
+import { BlockList, isIPv4, isIPv6 } from 'node:net';
+import { EnvironmentService } from '../environment/environment.service';
+import {
+ OutboundNetworkPolicy,
+ parseOutboundNetworkPolicy,
+ policyNamesAddress,
+} from './outbound-network-policy';
+
+export const OUTBOUND_LOOKUP = 'OUTBOUND_LOOKUP';
+
+export type ResolvedAddress = { address: string; family: number };
+export type LookupFn = (hostname: string) => Promise;
+export type PinnedAddress = { hostname: string; address: string; family: 4 | 6 };
+
+/** A rejected URL. Only transient resolution failures are retryable. */
+export class OutboundUrlError extends Error {
+ constructor(
+ message: string,
+ readonly retryable: boolean = false,
+ ) {
+ super(message);
+ this.name = 'OutboundUrlError';
+ }
+}
+
+export const defaultLookup: LookupFn = async (hostname) => {
+ const results = await dns.lookup(hostname, { all: true });
+ return results.map((r) => ({ address: r.address, family: r.family }));
+};
+
+// Reserved ranges blocked unless explicitly allowed on self-hosted deployments.
+const ALWAYS_BLOCKED = new BlockList();
+ALWAYS_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4'); // "this" network / unspecified
+ALWAYS_BLOCKED.addSubnet('127.0.0.0', 8, 'ipv4');
+ALWAYS_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local / cloud metadata
+ALWAYS_BLOCKED.addSubnet('192.0.0.0', 24, 'ipv4'); // IETF protocol assignments
+ALWAYS_BLOCKED.addSubnet('192.0.2.0', 24, 'ipv4'); // TEST-NET-1
+ALWAYS_BLOCKED.addSubnet('192.88.99.0', 24, 'ipv4'); // deprecated 6to4 relay anycast
+ALWAYS_BLOCKED.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking
+ALWAYS_BLOCKED.addSubnet('198.51.100.0', 24, 'ipv4'); // TEST-NET-2
+ALWAYS_BLOCKED.addSubnet('203.0.113.0', 24, 'ipv4'); // TEST-NET-3
+ALWAYS_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4'); // multicast + reserved
+ALWAYS_BLOCKED.addSubnet('::', 96, 'ipv6'); // deprecated IPv4-compatible
+ALWAYS_BLOCKED.addSubnet('::ffff:0:0:0', 96, 'ipv6'); // IPv4-translated (SIIT): ::ffff:0:7f00:1 is 127.0.0.1
+ALWAYS_BLOCKED.addSubnet('::', 128, 'ipv6'); // unspecified
+ALWAYS_BLOCKED.addSubnet('::1', 128, 'ipv6'); // loopback
+ALWAYS_BLOCKED.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known prefix
+ALWAYS_BLOCKED.addSubnet('64:ff9b:1::', 48, 'ipv6'); // NAT64 local-use
+ALWAYS_BLOCKED.addSubnet('100::', 64, 'ipv6'); // discard-only
+ALWAYS_BLOCKED.addSubnet('2001::', 32, 'ipv6'); // Teredo
+ALWAYS_BLOCKED.addSubnet('2001:db8::', 32, 'ipv6'); // documentation
+ALWAYS_BLOCKED.addSubnet('2002::', 16, 'ipv6'); // 6to4
+ALWAYS_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); // link-local
+ALWAYS_BLOCKED.addSubnet('fec0::', 10, 'ipv6'); // deprecated site-local
+ALWAYS_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); // multicast
+
+// Private ranges that self-hosted deployments can allow.
+const PRIVATE_NETWORKS = new BlockList();
+PRIVATE_NETWORKS.addSubnet('10.0.0.0', 8, 'ipv4');
+PRIVATE_NETWORKS.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT
+PRIVATE_NETWORKS.addSubnet('172.16.0.0', 12, 'ipv4');
+PRIVATE_NETWORKS.addSubnet('192.168.0.0', 16, 'ipv4');
+PRIVATE_NETWORKS.addSubnet('fc00::', 7, 'ipv6'); // unique-local
+
+// These ranges cannot be allowed by policy.
+const HARD_BLOCKED = new BlockList();
+HARD_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4');
+HARD_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4');
+HARD_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4');
+HARD_BLOCKED.addSubnet('::', 128, 'ipv6');
+HARD_BLOCKED.addSubnet('fe80::', 10, 'ipv6');
+HARD_BLOCKED.addSubnet('ff00::', 8, 'ipv6');
+
+/** Returns true for reserved or transition ranges. Invalid input is blocked. */
+export function isAlwaysBlockedAddress(ip: string): boolean {
+ if (isIPv4(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv4');
+ if (isIPv6(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv6');
+ return true;
+}
+
+/** Returns true for ranges that policy cannot allow. Invalid input is blocked. */
+export function isHardBlockedAddress(ip: string): boolean {
+ if (isIPv4(ip)) return HARD_BLOCKED.check(ip, 'ipv4');
+ if (isIPv6(ip)) return HARD_BLOCKED.check(ip, 'ipv6');
+ return true;
+}
+
+/** Returns true for private network ranges. Invalid input is blocked. */
+export function isPrivateNetworkAddress(ip: string): boolean {
+ if (isIPv4(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv4');
+ if (isIPv6(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv6');
+ return true;
+}
+
+/** Returns true for addresses blocked by cloud deployments. */
+export function isPrivateAddress(ip: string): boolean {
+ return isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip);
+}
+
+type Refusal = {
+ address: string;
+ kind: 'not-an-ip' | 'hard-blocked' | 'private' | 'reserved';
+};
+
+function findRefusal(
+ resolved: ResolvedAddress[],
+ port: number,
+ policy: OutboundNetworkPolicy,
+): Refusal | undefined {
+ for (const { address } of resolved) {
+ // Reject invalid resolver output before policy checks.
+ if (!isIPv4(address) && !isIPv6(address)) return { address, kind: 'not-an-ip' };
+ if (isHardBlockedAddress(address)) return { address, kind: 'hard-blocked' };
+ if (policyNamesAddress(policy, address, port)) continue;
+ if (isPrivateNetworkAddress(address)) {
+ if (policy.mode === 'all') continue;
+ return { address, kind: 'private' };
+ }
+ if (isAlwaysBlockedAddress(address)) return { address, kind: 'reserved' };
+ }
+ return undefined;
+}
+
+function describeRefusal(hostname: string, { address, kind }: Refusal): string {
+ if (kind === 'not-an-ip') {
+ return `Destination host "${hostname}" resolved to "${address}", which is not an IP address`;
+ }
+ if (kind === 'hard-blocked') {
+ return `Destination host "${hostname}" resolves to a link-local, metadata or reserved address (${address}), which is never allowed`;
+ }
+ const description =
+ kind === 'private' ? 'a private address' : 'a loopback or reserved address';
+ return `Destination host "${hostname}" resolves to ${description} (${address}). Set ALLOWED_PRIVATE_NETWORKS on the server to allow it`;
+}
+
+function effectivePort(url: URL): number {
+ if (url.port) return Number(url.port);
+ return url.protocol === 'https:' ? 443 : 80;
+}
+
+@Injectable()
+export class OutboundUrlGuard {
+ private readonly logger = new Logger(OutboundUrlGuard.name);
+ private readonly lookup: LookupFn;
+ private cachedPolicy?: { raw: string; policy: OutboundNetworkPolicy };
+
+ constructor(
+ private readonly environmentService: EnvironmentService,
+ @Optional() @Inject(OUTBOUND_LOOKUP) lookup?: LookupFn,
+ ) {
+ this.lookup = lookup ?? defaultLookup;
+ }
+
+ /** Caches the parsed policy and logs each invalid value once. */
+ private resolvePolicy(): OutboundNetworkPolicy {
+ const raw = this.environmentService.getAllowedPrivateNetworks();
+ if (this.cachedPolicy?.raw !== raw) {
+ const policy = parseOutboundNetworkPolicy(raw);
+ if (policy.invalid) {
+ this.logger.error(
+ `Invalid ALLOWED_PRIVATE_NETWORKS value "${raw}"; refusing every private and reserved destination`,
+ );
+ }
+ this.cachedPolicy = { raw, policy };
+ }
+ return this.cachedPolicy.policy;
+ }
+
+ /** Validates the URL and returns the address used to pin the connection. */
+ async validate(rawUrl: string): Promise {
+ let url: URL;
+ try {
+ url = new URL(rawUrl);
+ } catch {
+ throw new OutboundUrlError('Destination URL is not a valid URL');
+ }
+
+ const isCloud = this.environmentService.isCloud();
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
+ throw new OutboundUrlError('Destination URL must use http or https');
+ }
+ if (isCloud && url.protocol !== 'https:') {
+ throw new OutboundUrlError('Destination URL must use https');
+ }
+ if (url.username || url.password) {
+ throw new OutboundUrlError('Destination URL must not contain credentials');
+ }
+
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
+ let resolved: ResolvedAddress[];
+ if (isIPv4(hostname) || isIPv6(hostname)) {
+ resolved = [{ address: hostname, family: isIPv4(hostname) ? 4 : 6 }];
+ } else {
+ try {
+ resolved = await this.lookup(hostname);
+ } catch {
+ throw new OutboundUrlError(
+ `Could not resolve destination host "${hostname}"`,
+ true,
+ );
+ }
+ }
+ if (resolved.length === 0) {
+ throw new OutboundUrlError(
+ `Could not resolve destination host "${hostname}"`,
+ true,
+ );
+ }
+
+ if (isCloud) {
+ const blocked = resolved.find((r) => isPrivateAddress(r.address));
+ if (blocked) {
+ throw new OutboundUrlError(
+ `Destination host "${hostname}" resolves to a private or reserved address (${blocked.address}), which is not allowed`,
+ );
+ }
+ } else {
+ const refusal = findRefusal(
+ resolved,
+ effectivePort(url),
+ this.resolvePolicy(),
+ );
+ if (refusal) throw new OutboundUrlError(describeRefusal(hostname, refusal));
+ }
+
+ const pick = resolved[0];
+ return { hostname, address: pick.address, family: pick.family === 6 ? 6 : 4 };
+ }
+}
diff --git a/apps/server/src/integrations/outbound/outbound.module.ts b/apps/server/src/integrations/outbound/outbound.module.ts
new file mode 100644
index 000000000..0c0400dff
--- /dev/null
+++ b/apps/server/src/integrations/outbound/outbound.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from '@nestjs/common';
+import { OutboundAgentFactory } from './outbound-agent.factory';
+import { OutboundUrlGuard } from './outbound-url.guard';
+
+@Global()
+@Module({
+ providers: [OutboundUrlGuard, OutboundAgentFactory],
+ exports: [OutboundUrlGuard, OutboundAgentFactory],
+})
+export class OutboundModule {}
diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts
index 8b7c03a1f..a03c492d4 100644
--- a/apps/server/src/integrations/queue/constants/queue.constants.ts
+++ b/apps/server/src/integrations/queue/constants/queue.constants.ts
@@ -10,6 +10,7 @@ export enum QueueName {
NOTIFICATION_QUEUE = '{notification-queue}',
AUDIT_QUEUE = '{audit-queue}',
BASE_QUEUE = '{base-queue}',
+ SIEM_QUEUE = '{siem-queue}',
}
export enum QueueJob {
@@ -83,6 +84,9 @@ export enum QueueJob {
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
+ SIEM_SWEEP = 'siem-sweep',
+ SIEM_DELIVER = 'siem-deliver',
+
PDF_EXPORT_TASK = 'pdf-export-task',
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts
index 0c2c3c908..77bdf2b41 100644
--- a/apps/server/src/integrations/queue/queue.module.ts
+++ b/apps/server/src/integrations/queue/queue.module.ts
@@ -94,6 +94,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
attempts: 3,
},
}),
+ BullModule.registerQueue({
+ name: QueueName.SIEM_QUEUE,
+ defaultJobOptions: {
+ removeOnComplete: true,
+ removeOnFail: true,
+ attempts: 1,
+ },
+ }),
BullModule.registerQueue({
name: QueueName.BASE_QUEUE,
defaultJobOptions: {
diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts
index 4c9537526..9fa3cb6be 100644
--- a/apps/server/src/integrations/throttle/throttle.module.ts
+++ b/apps/server/src/integrations/throttle/throttle.module.ts
@@ -10,6 +10,7 @@ import {
OAUTH_REGISTER_THROTTLER,
OAUTH_TOKEN_THROTTLER,
OAUTH_AUTHORIZE_THROTTLER,
+ SIEM_TEST_THROTTLER,
} from './throttler-names';
import Redis from 'ioredis';
@@ -27,6 +28,7 @@ import Redis from 'ioredis';
{ name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 },
{ name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 },
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
+ { name: SIEM_TEST_THROTTLER, ttl: 60_000, limit: 10 },
],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(
diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts
index 898982976..e0c3b5afa 100644
--- a/apps/server/src/integrations/throttle/throttler-names.ts
+++ b/apps/server/src/integrations/throttle/throttler-names.ts
@@ -3,6 +3,7 @@ export const AI_CHAT_THROTTLER = 'ai-chat';
export const OAUTH_REGISTER_THROTTLER = 'oauth-register';
export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
+export const SIEM_TEST_THROTTLER = 'siem-test';
// Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
export const ALL_NAMED_THROTTLERS_SKIPPED: Record = {
@@ -11,4 +12,5 @@ export const ALL_NAMED_THROTTLERS_SKIPPED: Record = {
[OAUTH_REGISTER_THROTTLER]: true,
[OAUTH_TOKEN_THROTTLER]: true,
[OAUTH_AUTHORIZE_THROTTLER]: true,
+ [SIEM_TEST_THROTTLER]: true,
};
diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx
new file mode 100644
index 000000000..8f2fd9834
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx
@@ -0,0 +1,41 @@
+import { Section, Text } from 'react-email';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+type Props = {
+ destinationName: string;
+ destinationType: string;
+ lastError: string;
+ failingSince: string;
+ settingsLink: string;
+};
+
+export const SiemDestinationDisabledEmail = ({
+ destinationName,
+ destinationType,
+ lastError,
+ failingSince,
+ settingsLink,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ Your SIEM destination {destinationName} (
+ {destinationType}) has been failing since {failingSince} and was
+ disabled after 24 hours of failed deliveries.
+
+ Last error: {lastError}
+
+ Your events are kept and delivery resumes from where it stopped when
+ you re-enable it.
+
+
+ View destination
+
+ );
+};
+
+export default SiemDestinationDisabledEmail;
diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx
new file mode 100644
index 000000000..ada3a131d
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx
@@ -0,0 +1,41 @@
+import { Section, Text } from 'react-email';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+type Props = {
+ destinationName: string;
+ destinationType: string;
+ lastError: string;
+ failingSince: string;
+ settingsLink: string;
+};
+
+export const SiemDestinationFailingEmail = ({
+ destinationName,
+ destinationType,
+ lastError,
+ failingSince,
+ settingsLink,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ Docmost cannot deliver audit events to your SIEM destination{' '}
+ {destinationName} ({destinationType}).
+
+ Last error: {lastError}
+ Failing since {failingSince}.
+
+ Docmost keeps retrying every 30 minutes. If the destination is still
+ failing 24 hours after it started, it is disabled automatically.
+
+
+ View destination
+
+ );
+};
+
+export default SiemDestinationFailingEmail;
diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx
new file mode 100644
index 000000000..8da53ece0
--- /dev/null
+++ b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx
@@ -0,0 +1,35 @@
+import { Section, Text } from 'react-email';
+import * as React from 'react';
+import { content, paragraph } from '../css/styles';
+import { EmailButton, MailBody } from '../partials/partials';
+
+type Props = {
+ destinationName: string;
+ destinationType: string;
+ settingsLink: string;
+};
+
+export const SiemDestinationRecoveredEmail = ({
+ destinationName,
+ destinationType,
+ settingsLink,
+}: Props) => {
+ return (
+
+
+ Hi there,
+
+ Your SIEM destination {destinationName} (
+ {destinationType}) is delivering audit events again.
+
+
+ Events buffered during the outage were delivered from where the stream
+ stopped.
+
+
+ View destination
+
+ );
+};
+
+export default SiemDestinationRecoveredEmail;
From f4796c982e322d5eb7155e90d621fe954328380c Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Fri, 4 Sep 2026 14:56:08 +0100
Subject: [PATCH 24/27] sync
---
apps/server/src/ee | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/src/ee b/apps/server/src/ee
index c8ca1467d..32f166454 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit c8ca1467dfdde0499b6a6fc21c3425d648ba6a2e
+Subproject commit 32f16645436afd60ac92e8e39cd341eda2557f9f
From 876f3da1b26d3edf178e809a1b7ef1d79463df0e Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Sat, 5 Sep 2026 11:52:10 +0100
Subject: [PATCH 25/27] feat(beta): public spaces (#2473)
---
apps/client/package.json | 1 +
.../public/locales/en-US/translation.json | 77 ++
apps/client/src/App.tsx | 18 +
apps/client/src/ee/features.ts | 1 +
.../space-public-sharing-toggle.tsx | 1 +
.../space-viewer-comments-toggle.tsx | 1 +
.../editor/components/audio/audio-menu.tsx | 3 +
.../components/callout/callout-menu.tsx | 4 +-
.../components/columns/columns-menu.tsx | 3 +
.../editor/components/drawio/drawio-menu.tsx | 3 +
.../editor/components/image/image-menu.tsx | 3 +
.../editor/components/link/link-view.tsx | 85 +-
.../components/mention/mention-view.tsx | 75 +-
.../editor/components/pdf/pdf-menu.tsx | 3 +
.../components/subpages/subpages-menu.tsx | 3 +
.../components/subpages/subpages-view.tsx | 56 +-
.../table-of-contents/table-of-contents.tsx | 2 +-
.../transclusion-lookup-context.tsx | 17 +-
.../editor/components/video/video-menu.tsx | 3 +
.../features/editor/readonly-page-editor.tsx | 28 +-
.../page/components/header/page-header.tsx | 51 +-
apps/client/src/features/page/page.utils.ts | 13 +
.../page/tree/components/doc-tree-row.tsx | 5 +-
.../page/tree/components/doc-tree.tsx | 43 +-
.../public-space/atoms/public-space-atoms.ts | 17 +
.../components/appearance-settings.module.css | 51 +
.../components/appearance-settings.tsx | 165 +++
.../components/docs/docs-breadcrumbs.tsx | 114 ++
.../components/docs/docs-copy-page.tsx | 45 +
.../components/docs/docs-edit-page.tsx | 31 +
.../components/docs/docs-footer-branding.tsx | 23 +
.../components/docs/docs-hub.module.css | 485 ++++++++
.../components/docs/docs-page-nav.tsx | 70 ++
.../components/docs/docs-search-button.tsx | 22 +
.../components/docs/docs-shell.tsx | 182 +++
.../components/docs/docs-sidebar-tree.tsx | 198 +++
.../components/docs/docs-surface-context.tsx | 27 +
.../components/docs/docs-theme-toggle.tsx | 35 +
.../public-space/components/docs/docs-toc.tsx | 116 ++
.../components/docs/docs.module.css | 902 ++++++++++++++
.../components/public-space-layout.tsx | 69 ++
.../components/publish-space-settings.tsx | 283 +++++
.../components/published-spaces-list.tsx | 210 ++++
.../components/space-public-notice.tsx | 47 +
.../hooks/use-authenticated-user.ts | 14 +
.../hooks/use-docs-current-page.ts | 23 +
.../queries/public-space-query.ts | 109 ++
.../services/public-space-service.ts | 73 ++
.../features/public-space/theme/docs-theme.ts | 91 ++
.../public-space/types/public-space.types.ts | 105 ++
.../features/public-space/utils/docs-tree.ts | 39 +
.../public-space/utils/public-space-access.ts | 8 +
.../public-space-search-spotlight.tsx | 112 ++
apps/client/src/features/search/constants.ts | 3 +
.../features/search/queries/search-query.ts | 11 +
.../search/services/search-service.ts | 10 +
.../atoms/open-shared-tree-nodes-atom.ts | 3 -
.../features/share/atoms/shared-page-atom.ts | 13 +-
.../src/features/share/atoms/sidebar-atom.ts | 9 -
.../share/components/share-branding.tsx | 16 -
.../share/components/share-layout.tsx | 65 +-
.../features/share/components/share-shell.tsx | 272 ----
.../share/components/share.module.css | 36 -
.../features/share/components/shared-tree.tsx | 220 ----
.../share/hooks/use-shared-page-subpages.ts | 26 +-
.../features/share/hooks/use-toggle-toc.ts | 8 -
apps/client/src/features/share/utils.ts | 21 +
.../space/components/settings-modal.tsx | 31 +-
.../components/sidebar/space-sidebar.tsx | 3 +-
.../space/components/sidebar/switch-space.tsx | 22 +-
.../src/features/space/types/space.types.ts | 1 +
.../transclusion/services/transclusion-api.ts | 8 +
.../components/allow-public-spaces.tsx | 129 ++
.../workspace/types/workspace.types.ts | 8 +
apps/client/src/lib/api-client.ts | 7 +
apps/client/src/lib/config.ts | 4 +
.../public-space-directory-page.tsx | 235 ++++
.../pages/public-space/public-space-page.tsx | 125 ++
.../src/pages/settings/shares/shares.tsx | 48 +-
.../settings/workspace/workspace-settings.tsx | 10 +-
apps/client/src/pages/share/shared-page.tsx | 47 +-
apps/client/src/pages/space/space-home.tsx | 2 +
apps/client/src/styles/a11y-overrides.css | 10 +
apps/client/src/styles/public-typography.css | 39 +
apps/client/vite.config.ts | 2 +
apps/server/src/common/features.ts | 1 +
apps/server/src/core/core.module.ts | 2 +
.../core/public-space/dto/public-space.dto.ts | 79 ++
.../public-space-seo.controller.ts | 173 +++
.../public-space/public-space.controller.ts | 249 ++++
.../core/public-space/public-space.module.ts | 14 +
.../public-space/public-space.service.spec.ts | 1103 +++++++++++++++++
.../core/public-space/public-space.service.ts | 397 ++++++
apps/server/src/core/search/dto/search.dto.ts | 6 +
.../src/core/search/search.controller.spec.ts | 75 ++
.../src/core/search/search.controller.ts | 45 +-
apps/server/src/core/search/search.module.ts | 2 +
apps/server/src/core/search/search.service.ts | 12 +-
.../src/core/share/share-seo.controller.ts | 4 +-
apps/server/src/core/share/share.service.ts | 64 +-
.../workspace/dto/update-workspace.dto.ts | 8 +
.../services/workspace.service.spec.ts | 18 -
.../workspace/services/workspace.service.ts | 45 +
apps/server/src/database/database.module.ts | 3 +
.../20260904T171920-public-spaces.ts | 38 +
.../src/database/repos/page/page.repo.ts | 79 ++
.../repos/public-space/public-space.repo.ts | 196 +++
.../src/database/repos/space/space.repo.ts | 15 +
.../repos/workspace/workspace.repo.ts | 20 +
apps/server/src/database/types/db.d.ts | 13 +
.../server/src/database/types/entity.types.ts | 6 +
apps/server/src/ee | 2 +-
.../environment/environment.service.ts | 7 +
.../src/integrations/static/static.module.ts | 1 +
apps/server/src/main.ts | 6 +-
.../src/lib/markdown/utils/marked.utils.ts | 7 +-
pnpm-lock.yaml | 8 +
117 files changed, 7592 insertions(+), 715 deletions(-)
create mode 100644 apps/client/src/features/public-space/atoms/public-space-atoms.ts
create mode 100644 apps/client/src/features/public-space/components/appearance-settings.module.css
create mode 100644 apps/client/src/features/public-space/components/appearance-settings.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-copy-page.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-edit-page.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-hub.module.css
create mode 100644 apps/client/src/features/public-space/components/docs/docs-page-nav.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-search-button.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-shell.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-surface-context.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs-toc.tsx
create mode 100644 apps/client/src/features/public-space/components/docs/docs.module.css
create mode 100644 apps/client/src/features/public-space/components/public-space-layout.tsx
create mode 100644 apps/client/src/features/public-space/components/publish-space-settings.tsx
create mode 100644 apps/client/src/features/public-space/components/published-spaces-list.tsx
create mode 100644 apps/client/src/features/public-space/components/space-public-notice.tsx
create mode 100644 apps/client/src/features/public-space/hooks/use-authenticated-user.ts
create mode 100644 apps/client/src/features/public-space/hooks/use-docs-current-page.ts
create mode 100644 apps/client/src/features/public-space/queries/public-space-query.ts
create mode 100644 apps/client/src/features/public-space/services/public-space-service.ts
create mode 100644 apps/client/src/features/public-space/theme/docs-theme.ts
create mode 100644 apps/client/src/features/public-space/types/public-space.types.ts
create mode 100644 apps/client/src/features/public-space/utils/docs-tree.ts
create mode 100644 apps/client/src/features/public-space/utils/public-space-access.ts
create mode 100644 apps/client/src/features/search/components/public-space-search-spotlight.tsx
delete mode 100644 apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts
delete mode 100644 apps/client/src/features/share/atoms/sidebar-atom.ts
delete mode 100644 apps/client/src/features/share/components/share-branding.tsx
delete mode 100644 apps/client/src/features/share/components/share-shell.tsx
delete mode 100644 apps/client/src/features/share/components/shared-tree.tsx
delete mode 100644 apps/client/src/features/share/hooks/use-toggle-toc.ts
create mode 100644 apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx
create mode 100644 apps/client/src/pages/public-space/public-space-directory-page.tsx
create mode 100644 apps/client/src/pages/public-space/public-space-page.tsx
create mode 100644 apps/client/src/styles/public-typography.css
create mode 100644 apps/server/src/core/public-space/dto/public-space.dto.ts
create mode 100644 apps/server/src/core/public-space/public-space-seo.controller.ts
create mode 100644 apps/server/src/core/public-space/public-space.controller.ts
create mode 100644 apps/server/src/core/public-space/public-space.module.ts
create mode 100644 apps/server/src/core/public-space/public-space.service.spec.ts
create mode 100644 apps/server/src/core/public-space/public-space.service.ts
delete mode 100644 apps/server/src/core/workspace/services/workspace.service.spec.ts
create mode 100644 apps/server/src/database/migrations/20260904T171920-public-spaces.ts
create mode 100644 apps/server/src/database/repos/public-space/public-space.repo.ts
diff --git a/apps/client/package.json b/apps/client/package.json
index d836d5db6..188aabde3 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -21,6 +21,7 @@
"@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",
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index ef7db5d20..3dc11c1b3 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1345,6 +1345,83 @@
"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",
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index 1f7967c2c..e4238a0a6 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -44,6 +44,15 @@ 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) => ({
@@ -119,6 +128,15 @@ export default function App() {
} />
+ } />
+ }>
+ } />
+ }
+ />
+
+
} />
} />
} />
diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts
index a62462459..87bb559d5 100644
--- a/apps/client/src/ee/features.ts
+++ b/apps/client/src/ee/features.ts
@@ -25,5 +25,6 @@ export const Feature = {
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
+ PUBLIC_SPACE_APPEARANCE: 'public-space:appearance',
SIEM: 'siem',
} as const;
diff --git a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx
index 2b8b008fc..dc2b779d3 100644
--- a/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx
+++ b/apps/client/src/ee/security/components/space-public-sharing-toggle.tsx
@@ -82,6 +82,7 @@ export default function SpacePublicSharingToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
+ size={"xs"}
aria-label={t("Toggle space public sharing")}
/>
diff --git a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx
index 88fac67fa..6699eafa0 100644
--- a/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx
+++ b/apps/client/src/ee/security/components/space-viewer-comments-toggle.tsx
@@ -53,6 +53,7 @@ export default function SpaceViewerCommentsToggle({
checked={checked}
onChange={handleChange}
disabled={isDisabled}
+ size={"xs"}
aria-label={t("Toggle viewer comments")}
/>
diff --git a/apps/client/src/features/editor/components/audio/audio-menu.tsx b/apps/client/src/features/editor/components/audio/audio-menu.tsx
index eadc1afe5..382bc9afd 100644
--- a/apps/client/src/features/editor/components/audio/audio-menu.tsx
+++ b/apps/client/src/features/editor/components/audio/audio-menu.tsx
@@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) {
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
diff --git a/apps/client/src/features/editor/components/callout/callout-menu.tsx b/apps/client/src/features/editor/components/callout/callout-menu.tsx
index 3ce022dae..df64950b8 100644
--- a/apps/client/src/features/editor/components/callout/callout-menu.tsx
+++ b/apps/client/src/features/editor/components/callout/callout-menu.tsx
@@ -121,12 +121,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "bottom",
// offset: 233, // // offset: [0, 10],
- // zIndex: 99,
flip: false,
}}
shouldShow={shouldShow}
diff --git a/apps/client/src/features/editor/components/columns/columns-menu.tsx b/apps/client/src/features/editor/components/columns/columns-menu.tsx
index ce01324fd..d88d9f6ae 100644
--- a/apps/client/src/features/editor/components/columns/columns-menu.tsx
+++ b/apps/client/src/features/editor/components/columns/columns-menu.tsx
@@ -257,6 +257,9 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
index 418bd4de1..0872116af 100644
--- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
+++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx
@@ -273,6 +273,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx
index d6d9c9925..252e506f0 100644
--- a/apps/client/src/features/editor/components/image/image-menu.tsx
+++ b/apps/client/src/features/editor/components/image/image-menu.tsx
@@ -156,6 +156,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx
index daa5bb5de..39de1d667 100644
--- a/apps/client/src/features/editor/components/link/link-view.tsx
+++ b/apps/client/src/features/editor/components/link/link-view.tsx
@@ -26,7 +26,12 @@ 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 { buildSharedPageUrl } from "@/features/page/page.utils.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";
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
import { normalizeUrl } from "@/lib/utils";
@@ -60,9 +65,10 @@ export default function LinkView(props: MarkViewProps) {
const href = mark.attrs.href as string;
const navigate = useNavigate();
const location = useLocation();
- const { shareId, pageSlug } = useParams();
+ const { shareId, spaceSlug, pageSlug } = useParams();
const { t } = useTranslation();
const isShareRoute = location.pathname.startsWith("/share");
+ const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
const [popoverState, setPopoverState] = useState<
"closed" | "preview" | "edit"
@@ -84,16 +90,33 @@ export default function LinkView(props: MarkViewProps) {
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
- pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
+ pageId:
+ isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute
+ ? slugId
+ : null,
});
const { data: sharedPageData } = useSharePageQuery({
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
});
- const pageTitle = isShareRoute
- ? sharedPageData?.page?.title
- : linkedPage?.title;
+ // 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 pendingTitleRef = useRef(null);
const titleInputRef = useRef(null);
@@ -260,6 +283,27 @@ 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);
}
@@ -276,8 +320,11 @@ export default function LinkView(props: MarkViewProps) {
location.pathname,
isInternal,
isShareRoute,
+ isPublicSpaceRoute,
slugId,
shareId,
+ spaceSlug,
+ publicSpacePageData,
pageTitle,
pageSlug,
]);
@@ -319,12 +366,18 @@ 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
- ? isShareRoute && slugId
- ? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
- : href
- : normalizeUrl(href),
+ isInternal ? internalHref() : normalizeUrl(href),
);
const linkTitleInput = (
@@ -374,6 +427,16 @@ 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 (
+
+
+
+ );
+ }
+
return (
)}
- {isPageMention && !isShareRoute && isError && (
+ {isPageMention && isPublicSpaceRoute && publicPageData?.page && (
+
+
+
+
+
+ {publicPageData.page.title || label}
+
+
+ )}
+
+ {/* 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 && (
+
+
+
+
+ {label}
+
+ )}
+
+ {isPageMention && !isShareRoute && !isPublicSpaceRoute && isError && (
)}
- {isPageMention && !isShareRoute && !isError && (
+ {isPageMention && !isShareRoute && !isPublicSpaceRoute && !isError && (
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
diff --git a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx
index 776568037..190893607 100644
--- a/apps/client/src/features/editor/components/subpages/subpages-menu.tsx
+++ b/apps/client/src/features/editor/components/subpages/subpages-menu.tsx
@@ -61,6 +61,9 @@ export const SubpagesMenu = React.memo(
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
shouldShow={shouldShow}
>
diff --git a/apps/client/src/features/editor/components/subpages/subpages-view.tsx b/apps/client/src/features/editor/components/subpages/subpages-view.tsx
index a50207aa0..0683a22e1 100644
--- a/apps/client/src/features/editor/components/subpages/subpages-view.tsx
+++ b/apps/client/src/features/editor/components/subpages/subpages-view.tsx
@@ -3,22 +3,30 @@ 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, useParams } from "react-router-dom";
+import { Link, useLocation, 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;
@@ -29,11 +37,25 @@ 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(
- shareId ? null : { pageId: currentPageId },
+ isPublicView ? null : { pageId: currentPageId },
);
const subpages = useMemo(() => {
@@ -48,17 +70,33 @@ 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]);
+ }, [
+ data,
+ shareId,
+ sharedSubpages,
+ isPublicSpaceRoute,
+ publicSpaceSubpages,
+ ]);
- if (isLoading && !shareId) {
+ if (isLoading && !isPublicView) {
return null;
}
- if (error && !shareId) {
+ if (error && !isPublicView) {
return (
@@ -96,7 +134,13 @@ export default function SubpagesView(props: NodeViewProps) {
pageSlugId: page.slugId,
pageTitle: page.title,
})
- : buildPageUrl(spaceSlug, page.slugId, page.title)
+ : isPublicSpaceRoute
+ ? buildPublicSpaceUrl({
+ spaceSlug,
+ pageSlugId: page.slugId,
+ pageTitle: page.title,
+ })
+ : buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx
index ba2bed40e..c4a082db0 100644
--- a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx
+++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx
@@ -18,7 +18,7 @@ export type HeadingLink = {
position: number;
};
-const recalculateLinks = (nodePos: NodePos[]) => {
+export const recalculateLinks = (nodePos: NodePos[]) => {
const nodes: HTMLElement[] = [];
const links: HeadingLink[] = Array.from(nodePos).reduce(
diff --git a/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx
index ec44a5f20..6e3a3c708 100644
--- a/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx
+++ b/apps/client/src/features/editor/components/transclusion/transclusion-lookup-context.tsx
@@ -9,6 +9,7 @@ import React, {
} from "react";
import {
lookupTransclusion,
+ lookupTransclusionForPublicSpace,
lookupTransclusionForShare,
} from "@/features/transclusion/services/transclusion-api";
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
@@ -38,6 +39,7 @@ const TransclusionLookupContext = createContext(null);
export function TransclusionLookupProvider({
children,
shareId,
+ spaceSlug,
}: {
children: React.ReactNode;
/**
@@ -47,6 +49,11 @@ 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());
const queueRef = useRef(new Set());
@@ -55,6 +62,8 @@ export function TransclusionLookupProvider({
// memoized callbacks (and thus doesn't re-render every consumer).
const shareIdRef = useRef(shareId);
shareIdRef.current = shareId;
+ const spaceSlugRef = useRef(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.
@@ -91,12 +100,18 @@ export function TransclusionLookupProvider({
try {
const activeShareId = shareIdRef.current;
+ const activeSpaceSlug = spaceSlugRef.current;
const { items } = activeShareId
? await lookupTransclusionForShare({
shareId: activeShareId,
references,
})
- : await lookupTransclusion({ references });
+ : activeSpaceSlug
+ ? await lookupTransclusionForPublicSpace({
+ spaceSlug: activeSpaceSlug,
+ references,
+ })
+ : await lookupTransclusion({ references });
for (const r of items) {
const key = `${r.sourcePageId}::${r.transclusionId}`;
resultCacheRef.current.set(key, r);
diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx
index 0e01fe8ba..9cf8e3fd0 100644
--- a/apps/client/src/features/editor/components/video/video-menu.tsx
+++ b/apps/client/src/features/editor/components/video/video-menu.tsx
@@ -132,6 +132,9 @@ export function VideoMenu({ editor }: EditorMenuProps) {
{
+ if (element) element.style.zIndex = "99";
+ }}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx
index df2f6e470..f99bfc04a 100644
--- a/apps/client/src/features/editor/readonly-page-editor.tsx
+++ b/apps/client/src/features/editor/readonly-page-editor.tsx
@@ -35,6 +35,16 @@ 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({
@@ -43,12 +53,16 @@ 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(null);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
+ const isPublicView = Boolean(shareId || spaceSlug);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
@@ -64,9 +78,9 @@ export default function ReadonlyPageEditor({
}, []);
useEffect(() => {
- if (!shareId) return;
+ if (!isPublicView) return;
setLightboxRequest(null);
- }, [pageId, shareId]);
+ }, [pageId, isPublicView]);
const extensions = useMemo(() => {
const excludedExtensions = new Set([
@@ -99,7 +113,7 @@ export default function ReadonlyPageEditor({
];
return (
-
+
+ {byline}
+
{
const request = getLightboxClickRequest(node);
@@ -144,7 +160,7 @@ export default function ReadonlyPageEditor({
}
}}
>
- {shareId && contentEditor && (
+ {isPublicView && contentEditor && (
setLightboxRequest(null)}
/>
)}
-
+ {trailingSpace &&
}
);
}
diff --git a/apps/client/src/features/page/components/header/page-header.tsx b/apps/client/src/features/page/components/header/page-header.tsx
index 0614cf0bd..aec7038a8 100644
--- a/apps/client/src/features/page/components/header/page-header.tsx
+++ b/apps/client/src/features/page/components/header/page-header.tsx
@@ -1,16 +1,63 @@
import classes from "./page-header.module.css";
import PageHeaderMenu from "@/features/page/components/header/page-header-menu.tsx";
-import { Group } from "@mantine/core";
+import { Badge, Group, Tooltip } from "@mantine/core";
+import { IconExternalLink, IconWorld } from "@tabler/icons-react";
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 (
-
+
+
+
+ {showPublicBadge && (
+
+ }
+ rightSection={ }
+ style={{ flexShrink: 0, cursor: "pointer" }}
+ >
+ {t("Public")}
+
+
+ )}
+
diff --git a/apps/client/src/features/page/page.utils.ts b/apps/client/src/features/page/page.utils.ts
index 8b0111a28..638ba506a 100644
--- a/apps/client/src/features/page/page.utils.ts
+++ b/apps/client/src/features/page/page.utils.ts
@@ -55,3 +55,16 @@ export const buildSharedPageUrl = (opts: {
}
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;
+};
diff --git a/apps/client/src/features/page/tree/components/doc-tree-row.tsx b/apps/client/src/features/page/tree/components/doc-tree-row.tsx
index e3aebe9e9..7c9df04b9 100644
--- a/apps/client/src/features/page/tree/components/doc-tree-row.tsx
+++ b/apps/client/src/features/page/tree/components/doc-tree-row.tsx
@@ -41,6 +41,7 @@ type Props = {
activeId?: string;
renderRow: (props: RenderRowProps) => ReactNode;
indentPerLevel: number;
+ rowClassName?: string;
onMove: (sourceId: string, op: DropOp) => void | Promise;
onToggle: (id: string, isOpen: boolean) => void;
readOnly: boolean;
@@ -68,6 +69,7 @@ function DocTreeRowInner(props: Props) {
activeId,
renderRow,
indentPerLevel,
+ rowClassName,
onMove,
onToggle,
readOnly,
@@ -323,7 +325,7 @@ function DocTreeRowInner(props: Props) {
style={{ paddingLeft: level * indentPerLevel }}
>
(
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;
diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx
index 3dbbfda16..299797333 100644
--- a/apps/client/src/features/page/tree/components/doc-tree.tsx
+++ b/apps/client/src/features/page/tree/components/doc-tree.tsx
@@ -56,6 +56,18 @@ export type DocTreeProps
= {
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, 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;
onToggle: (id: string, isOpen: boolean) => void;
@@ -123,6 +135,9 @@ function DocTreeInner(
renderRow,
indentPerLevel = 16,
rowHeight = 32,
+ rowClassName,
+ rowGap,
+ dynamicRowHeight = false,
onMove,
onToggle,
onSelect,
@@ -132,6 +147,7 @@ function DocTreeInner(
getDragLabel,
uniqueContextId,
emptyState,
+ footer,
'aria-label': ariaLabel,
} = props;
@@ -197,10 +213,20 @@ function DocTreeInner(
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,
- estimateSize: () => rowHeight,
+ 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;
+ },
overscan: 10,
});
@@ -471,7 +497,12 @@ function DocTreeInner(
);
if (data.length === 0 && emptyState) {
- return {emptyState}
;
+ return (
+
+ {emptyState}
+ {footer}
+
+ );
}
const virtualItems = virtualizer.getVirtualItems();
@@ -494,6 +525,9 @@ function DocTreeInner(
>
{virtualItems.map((virtualItem) => {
const row = flat[virtualItem.index];
+ const gap = rowGap
+ ? rowGap(row.node, row.level, virtualItem.index)
+ : 0;
return (
(
// (the row's ), so screen readers announce "treeitem" on
// navigation. The 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 } : {}),
}}
>
(
activeId={effectiveActiveId}
renderRow={renderRow}
indentPerLevel={indentPerLevel}
+ rowClassName={rowClassName}
onMove={onMove}
onToggle={onToggle}
readOnly={readOnly}
@@ -532,6 +570,7 @@ function DocTreeInner(
);
})}
+ {footer}
);
}
diff --git a/apps/client/src/features/public-space/atoms/public-space-atoms.ts b/apps/client/src/features/public-space/atoms/public-space-atoms.ts
new file mode 100644
index 000000000..8388d7c2b
--- /dev/null
+++ b/apps/client/src/features/public-space/atoms/public-space-atoms.ts
@@ -0,0 +1,17 @@
+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(
+ null as IPublicSpaceTree | null,
+);
+
+export const publicSpaceTreeDataAtom = atom(
+ null as SharedPageTreeNode[] | null,
+);
+
+export const openPublicSpaceTreeNodesAtom = atom>({});
+
+export const docsMobileSidebarAtom = atom(false);
+
+export const docsMobileTocAtom = atom(false);
diff --git a/apps/client/src/features/public-space/components/appearance-settings.module.css b/apps/client/src/features/public-space/components/appearance-settings.module.css
new file mode 100644
index 000000000..c37de65ab
--- /dev/null
+++ b/apps/client/src/features/public-space/components/appearance-settings.module.css
@@ -0,0 +1,51 @@
+.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);
+}
diff --git a/apps/client/src/features/public-space/components/appearance-settings.tsx b/apps/client/src/features/public-space/components/appearance-settings.tsx
new file mode 100644
index 000000000..8bf4ea604
--- /dev/null
+++ b/apps/client/src/features/public-space/components/appearance-settings.tsx
@@ -0,0 +1,165 @@
+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 (
+
+
+ {t("Appearance")}
+
+
+ {t("Choose the primary color of the public docs site.")}
+
+
+
+
+ {DOCS_THEME_PRESETS.map((preset) => {
+ const selected = !customOpen && matchedPreset?.id === preset.id;
+ return (
+ selectPreset(preset.id)}
+ >
+
+ {t(preset.nameKey)}
+
+ );
+ })}
+
+ setCustomOpen(true)}
+ >
+
+
+
+ {t("Custom")}
+
+
+
+
+ {customOpen && hasAppearance && (
+
+ {
+ setCustomLight(value);
+ commitCustom(value, customDark);
+ }}
+ />
+ {
+ setCustomDark(value);
+ commitCustom(customLight, value);
+ }}
+ />
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx b/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx
new file mode 100644
index 000000000..d2d194a40
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-breadcrumbs.tsx
@@ -0,0 +1,114 @@
+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(() => {
+ 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(
+
+ {crumbs[0].name}
+ ,
+ );
+ }
+ if (collapsed) {
+ items.push(
+
+
+
+ …
+
+
+
+ {hidden.map((item) => (
+
+ {item.name}
+
+ ))}
+
+ ,
+ );
+ }
+ const trailing = collapsed ? [crumbs[crumbs.length - 1]] : crumbs;
+ for (const crumb of trailing) {
+ items.push(
+
+ {crumb.name}
+ ,
+ );
+ }
+
+ return (
+
+ {items.map((item, index) => (
+
+ {index > 0 && (
+
+ /
+
+ )}
+ {item}
+
+ ))}
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx
new file mode 100644
index 000000000..119407dad
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx
@@ -0,0 +1,45 @@
+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 (
+
+ ) : (
+
+ )
+ }
+ >
+ {clipboard.copied ? t("Copied") : t("Copy page")}
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx
new file mode 100644
index 000000000..77ef37c9b
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx
@@ -0,0 +1,31 @@
+import { Link, useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { IconPencil } from "@tabler/icons-react";
+import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts";
+import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts";
+import { buildPageUrl } from "@/features/page/page.utils.ts";
+import styles from "./docs.module.css";
+
+export default function DocsEditPage() {
+ const { t } = useTranslation();
+ const { spaceSlug } = useParams();
+ const page = useDocsCurrentPage();
+
+ const { data: currentUser } = useAuthenticatedUser();
+
+ if (!currentUser?.user || !page) {
+ return null;
+ }
+
+ return (
+
+
+ {t("Edit page")}
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx
new file mode 100644
index 000000000..9e55ebba3
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx
@@ -0,0 +1,23 @@
+import clsx from "clsx";
+import styles from "./docs.module.css";
+
+export default function DocsFooterBranding({
+ className,
+ refSource = "public-space",
+}: {
+ className?: string;
+ refSource?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-hub.module.css b/apps/client/src/features/public-space/components/docs/docs-hub.module.css
new file mode 100644
index 000000000..53a93362c
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-hub.module.css
@@ -0,0 +1,485 @@
+/* Design tokens for the public docs hub, transcribed from the "1a solid brand
+ * band" direction of the Directory design spec (a fixed light look). Declared
+ * on :root like --docs-* so cover/brand customization can override them at
+ * runtime. */
+:root {
+ --docs-hub-max: 80rem;
+ --docs-hub-bg: #ffffff;
+ --docs-hub-fg: #111827;
+ --docs-hub-muted: #5b6270;
+ --docs-hub-nav-fg: #4b5563;
+ --docs-hub-footer-fg: #6b7280;
+ --docs-hub-border: #eceef2;
+ --docs-hub-brand: #12275c;
+ --docs-hub-brand-fg: #ffffff;
+ --docs-hub-hero-bg: var(--docs-hub-brand);
+ --docs-hub-hero-fg: #ffffff;
+ --docs-hub-hero-muted: rgba(255, 255, 255, 0.78);
+ --docs-hub-search-bg: #ffffff;
+ --docs-hub-search-fg: #111827;
+ --docs-hub-search-placeholder: #8a919e;
+ --docs-hub-search-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
+ --docs-hub-card-bg: #ffffff;
+ --docs-hub-card-border: #e6e7ea;
+ --docs-hub-card-radius: 14px;
+ --docs-hub-card-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
+ --docs-hub-card-border-hover: #c4c8d0;
+ --docs-hub-card-shadow-hover: 0 12px 32px rgba(15, 23, 42, 0.14);
+ --docs-hub-tile-fg: #ffffff;
+}
+
+.root {
+ min-height: 100dvh;
+ background-color: var(--docs-hub-bg);
+ color: var(--docs-hub-fg);
+}
+
+.container {
+ max-width: var(--docs-hub-max);
+ margin-inline: auto;
+}
+
+/* ---------- Top bar ---------- */
+
+.topBar {
+ border-bottom: 1px solid var(--docs-hub-border);
+ padding: 0 rem(48px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding: 0 rem(20px);
+ }
+}
+
+.topBarInner {
+ height: rem(64px);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ height: rem(56px);
+ }
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: rem(10px);
+ min-width: 0;
+ color: var(--docs-hub-fg);
+ text-decoration: none;
+ font-size: rem(16px);
+ font-weight: 600;
+ border-radius: rem(8px);
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-hub-brand);
+ outline-offset: 4px;
+ }
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ gap: rem(8px);
+ font-size: rem(15px);
+ }
+}
+
+.brandTile {
+ width: rem(28px);
+ height: rem(28px);
+ border-radius: rem(8px);
+ background-color: var(--docs-hub-brand);
+ color: var(--docs-hub-brand-fg);
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ font-weight: 700;
+ font-size: rem(13px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ width: rem(26px);
+ height: rem(26px);
+ border-radius: rem(7px);
+ font-size: rem(12px);
+ }
+}
+
+.topActions {
+ display: flex;
+ align-items: center;
+ gap: rem(24px);
+ font-size: rem(14px);
+ color: var(--docs-hub-nav-fg);
+}
+
+.signIn {
+ display: inline-block;
+ padding: rem(8px) rem(14px);
+ border-radius: rem(8px);
+ background-color: var(--docs-hub-brand);
+ color: var(--docs-hub-brand-fg);
+ text-decoration: none;
+ font-weight: 500;
+ white-space: nowrap;
+
+ @media (hover: hover) {
+ &:hover {
+ filter: brightness(1.15);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-hub-brand);
+ outline-offset: 2px;
+ }
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding: rem(8px) rem(12px);
+ font-size: rem(13px);
+ }
+}
+
+/* ---------- Hero band ---------- */
+
+.hero {
+ background-color: var(--docs-hub-hero-bg);
+ color: var(--docs-hub-hero-fg);
+ padding: rem(72px) rem(48px) rem(80px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding: rem(40px) rem(20px) rem(44px);
+ }
+}
+
+.heroInner {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: rem(16px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ gap: rem(10px);
+ }
+}
+
+.heading {
+ margin: 0;
+ color: var(--docs-hub-hero-fg);
+ font-size: rem(48px);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ font-size: rem(30px);
+ }
+}
+
+.subtitle {
+ margin: 0;
+ color: var(--docs-hub-hero-muted);
+ font-size: rem(18px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ font-size: rem(15px);
+ line-height: 1.45;
+ }
+}
+
+.search {
+ margin-top: rem(16px);
+ width: rem(640px);
+ max-width: 100%;
+ height: rem(56px);
+ display: flex;
+ align-items: center;
+ padding: 0 rem(6px) 0 rem(22px);
+ border-radius: 999px;
+ background-color: var(--docs-hub-search-bg);
+ box-shadow: var(--docs-hub-search-shadow);
+
+ &:focus-within {
+ outline: 2px solid var(--docs-hub-hero-fg);
+ outline-offset: 3px;
+ }
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ margin-top: rem(10px);
+ width: 100%;
+ height: rem(48px);
+ padding: 0 rem(5px) 0 rem(18px);
+ box-shadow: none;
+ }
+}
+
+.searchInput {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ border: 0;
+ background: transparent;
+ font: inherit;
+ font-size: rem(16px);
+ color: var(--docs-hub-search-fg);
+ text-align: left;
+ outline: none;
+
+ &::placeholder {
+ color: var(--docs-hub-search-placeholder);
+ }
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ font-size: rem(15px);
+ }
+}
+
+.searchButton {
+ width: rem(44px);
+ height: rem(44px);
+ flex-shrink: 0;
+ border: 0;
+ border-radius: 999px;
+ background-color: var(--docs-hub-brand);
+ color: var(--docs-hub-brand-fg);
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-hub-search-fg);
+ outline-offset: 2px;
+ }
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ width: rem(38px);
+ height: rem(38px);
+ }
+}
+
+/* ---------- Spaces ---------- */
+
+.main {
+ padding: rem(48px) rem(48px) rem(56px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding: rem(24px) rem(20px) rem(28px);
+ }
+}
+
+.mainInner {
+ display: flex;
+ flex-direction: column;
+ gap: rem(24px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ gap: rem(16px);
+ }
+}
+
+.sectionHeader {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: rem(16px);
+ border-bottom: 1px solid var(--docs-hub-border);
+ padding-bottom: rem(14px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding-bottom: rem(10px);
+ }
+}
+
+.sectionTitle {
+ margin: 0;
+ font-size: rem(13px);
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--docs-hub-fg);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ font-size: rem(12px);
+ }
+}
+
+.sectionCount {
+ font-size: rem(14px);
+ color: var(--docs-hub-footer-fg);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ font-size: rem(13px);
+ }
+}
+
+/* Phones flow one card per row; wider viewports fit two, then 3/4 columns. */
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(rem(220px), 1fr));
+ gap: rem(12px);
+
+ @media (min-width: $mantine-breakpoint-sm) {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: rem(20px);
+ }
+
+ @media (min-width: 64em) {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: rem(14px);
+ padding: rem(24px) rem(24px) rem(26px);
+ background-color: var(--docs-hub-card-bg);
+ border: 1px solid var(--docs-hub-card-border);
+ border-radius: var(--docs-hub-card-radius);
+ box-shadow: var(--docs-hub-card-shadow);
+ color: inherit;
+ text-decoration: none;
+ transition:
+ border-color 120ms ease,
+ box-shadow 160ms ease;
+
+ @media (hover: hover) {
+ &:hover {
+ border-color: var(--docs-hub-card-border-hover);
+ box-shadow: var(--docs-hub-card-shadow-hover);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-hub-brand);
+ outline-offset: 2px;
+ }
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ gap: rem(12px);
+ padding: rem(18px) rem(18px) rem(20px);
+ }
+}
+
+/* Tile and title share a row so titles get the card's full width. */
+.cardHeader {
+ display: flex;
+ align-items: center;
+ gap: rem(14px);
+ min-width: 0;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ gap: rem(12px);
+ }
+}
+
+.cardTile {
+ width: rem(40px);
+ height: rem(40px);
+ flex-shrink: 0;
+ border-radius: rem(10px);
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ color: var(--docs-hub-tile-fg);
+ font-weight: 700;
+ font-size: rem(15px);
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ width: rem(36px);
+ height: rem(36px);
+ border-radius: rem(9px);
+ font-size: rem(14px);
+ }
+}
+
+.cardName {
+ min-width: 0;
+ font-size: rem(18px);
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--docs-hub-fg);
+ text-wrap: balance;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ font-size: rem(16px);
+ }
+}
+
+.cardDescription {
+ font-size: rem(15px);
+ line-height: 1.5;
+ color: var(--docs-hub-muted);
+ text-wrap: pretty;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ font-size: rem(14px);
+ }
+}
+
+.empty {
+ margin: 0;
+ padding: rem(48px) 0;
+ text-align: center;
+ color: var(--docs-hub-muted);
+}
+
+/* ---------- Footer ---------- */
+
+.footer {
+ border-top: 1px solid var(--docs-hub-border);
+ padding: rem(24px) rem(48px);
+ font-size: rem(14px);
+ color: var(--docs-hub-footer-fg);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding: rem(20px);
+ font-size: rem(13px);
+ }
+}
+
+.footerInner {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: rem(12px);
+ }
+}
+
+.footerBranding {
+ color: var(--docs-hub-fg);
+ font-weight: 600;
+ text-decoration: none;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-hub-brand);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-hub-brand);
+ outline-offset: 2px;
+ border-radius: rem(2px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .card {
+ transition: none;
+ }
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx
new file mode 100644
index 000000000..f98d3ee93
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx
@@ -0,0 +1,70 @@
+import { useMemo } from "react";
+import { Link, useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react";
+import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
+import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts";
+import { extractPageSlugId } from "@/lib";
+import { SharedPageTreeNode } from "@/features/share/utils.ts";
+import styles from "./docs.module.css";
+
+export default function DocsPageNav() {
+ const { t } = useTranslation();
+ const { pageSlug } = useParams();
+ const { treeData, getNodeUrl } = useDocsSurface();
+
+ const { prev, next } = useMemo(() => {
+ if (!treeData?.length) {
+ return {
+ prev: null as SharedPageTreeNode | null,
+ next: null as SharedPageTreeNode | null,
+ };
+ }
+ const flat = flattenTreePreorder(treeData);
+ const currentSlugId = pageSlug
+ ? extractPageSlugId(pageSlug)
+ : treeData[0]?.slugId;
+ const index = flat.findIndex((node) => node.slugId === currentSlugId);
+ return {
+ prev: index > 0 ? flat[index - 1] : null,
+ next: index >= 0 && index < flat.length - 1 ? flat[index + 1] : null,
+ };
+ }, [treeData, pageSlug]);
+
+ if (!prev && !next) return null;
+
+ return (
+
+ {prev && (
+
+
+
+ {t("Previous")}
+
+
+ {prev.name || t("untitled")}
+
+
+ )}
+ {next && (
+
+
+ {t("Next")}
+
+
+
+ {next.name || t("untitled")}
+
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-search-button.tsx b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx
new file mode 100644
index 000000000..20fc8e778
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx
@@ -0,0 +1,22 @@
+import { IconSearch } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { platformModifierLabel } from "@/lib";
+import styles from "./docs.module.css";
+
+type DocsSearchButtonProps = {
+ onClick: () => void;
+};
+
+export default function DocsSearchButton({ onClick }: DocsSearchButtonProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t("Search")}
+
+ {platformModifierLabel} K
+
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-shell.tsx b/apps/client/src/features/public-space/components/docs/docs-shell.tsx
new file mode 100644
index 000000000..1b630644f
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-shell.tsx
@@ -0,0 +1,182 @@
+import React from "react";
+import { ActionIcon, Drawer, Tooltip } from "@mantine/core";
+import { Link } from "react-router-dom";
+import { useAtom } from "jotai";
+import { useTranslation } from "react-i18next";
+import { IconList, IconMenu2 } from "@tabler/icons-react";
+import clsx from "clsx";
+import {
+ docsMobileSidebarAtom,
+ docsMobileTocAtom,
+} from "@/features/public-space/atoms/public-space-atoms.ts";
+import {
+ DocsSurface,
+ DocsSurfaceProvider,
+} from "@/features/public-space/components/docs/docs-surface-context.tsx";
+import DocsSidebarTree from "@/features/public-space/components/docs/docs-sidebar-tree.tsx";
+import DocsToc from "@/features/public-space/components/docs/docs-toc.tsx";
+import DocsEditPage from "@/features/public-space/components/docs/docs-edit-page.tsx";
+import DocsCopyPage from "@/features/public-space/components/docs/docs-copy-page.tsx";
+import DocsSearchButton from "@/features/public-space/components/docs/docs-search-button.tsx";
+import DocsThemeToggle from "@/features/public-space/components/docs/docs-theme-toggle.tsx";
+import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx";
+import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
+import { SearchMobileControl } from "@/features/search/components/search-control.tsx";
+import styles from "./docs.module.css";
+
+const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree);
+
+type DocsShellProps = {
+ surface: DocsSurface;
+ onSearchOpen?: () => void;
+ searchSpotlight?: React.ReactNode;
+ children: React.ReactNode;
+};
+
+export default function DocsShell({
+ surface,
+ onSearchOpen,
+ searchSpotlight,
+ children,
+}: DocsShellProps) {
+ const { t } = useTranslation();
+ const { hasSidebar, siteName, homeUrl, showBranding, showEditPage } = surface;
+
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useAtom(
+ docsMobileSidebarAtom,
+ );
+ const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom);
+
+ return (
+
+
+
+
+
+
+
+
+ {hasSidebar && (
+ <>
+ {siteName && homeUrl && (
+ <>
+
+ {siteName}
+
+
+ >
+ )}
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ setMobileTocOpen(true)}
+ size="md"
+ aria-label={t("Table of contents")}
+ >
+
+
+
+
+
+ {children}
+ {showBranding && (
+
+ )}
+
+
+
+
+
+
+
setMobileSidebarOpen(false)}
+ title={siteName}
+ size={300}
+ padding="sm"
+ >
+
+ {hasSidebar && }
+
+
+
+
setMobileTocOpen(false)}
+ position="right"
+ size={300}
+ padding="md"
+ >
+
+ {showEditPage && }
+
+
+ {searchSpotlight}
+
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx
new file mode 100644
index 000000000..183fae8e9
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx
@@ -0,0 +1,198 @@
+import { SharedPageTreeNode } from "@/features/share/utils.ts";
+import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { Link, useParams } from "react-router-dom";
+import { useAtom, useSetAtom } from "jotai";
+import { useTranslation } from "react-i18next";
+import { IconChevronRight } from "@tabler/icons-react";
+import { ActionIcon } from "@mantine/core";
+import { extractPageSlugId } from "@/lib";
+import {
+ DocTree,
+ type DocTreeApi,
+ type RenderRowProps,
+} from "@/features/page/tree/components/doc-tree";
+import {
+ docsMobileSidebarAtom,
+ openPublicSpaceTreeNodesAtom,
+} from "@/features/public-space/atoms/public-space-atoms.ts";
+import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
+import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts";
+import styles from "./docs.module.css";
+
+export default function DocsSidebarTree() {
+ const { t } = useTranslation();
+ const treeRef = useRef(null);
+ const { pageSlug } = useParams();
+ const { treeData, getNodeUrl } = useDocsSurface();
+ const [openTreeNodes, setOpenTreeNodes] = useAtom(
+ openPublicSpaceTreeNodesAtom,
+ );
+
+ // The first root page is the surface home, served at the bare URL.
+ const firstRootSlugId = treeData?.[0]?.slugId;
+
+ const currentNodeId = pageSlug ? extractPageSlugId(pageSlug) : firstRootSlugId;
+
+ const openIds = useMemo(
+ () => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])),
+ [openTreeNodes],
+ );
+
+ useEffect(() => {
+ // Auto-open the first level of the tree on initial load.
+ const root = treeData?.[0];
+ if (!root) return;
+ setOpenTreeNodes((prev) => {
+ if (prev[root.slugId]) return prev;
+ const next = { ...prev, [root.slugId]: true };
+ for (const child of root.children ?? []) {
+ next[child.slugId] = true;
+ }
+ return next;
+ });
+ }, [treeData, setOpenTreeNodes]);
+
+ // Reveal the current page: expand its ancestor trail (deep links land with
+ // everything collapsed otherwise) and the page itself when it has children.
+ useEffect(() => {
+ if (!currentNodeId || !treeData?.length) return;
+ const trail = findAncestorTrail(treeData, currentNodeId);
+ if (trail === null) return;
+ setOpenTreeNodes((prev) => {
+ const next = { ...prev };
+ let changed = false;
+ for (const node of [...trail.map((n) => n.slugId), currentNodeId]) {
+ if (!next[node]) {
+ next[node] = true;
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
+ });
+ }, [currentNodeId, treeData, setOpenTreeNodes]);
+
+ useEffect(() => {
+ if (currentNodeId) {
+ treeRef.current?.select(currentNodeId, { scrollIntoView: true });
+ }
+ }, [currentNodeId, treeData]);
+
+ const handleToggle = useCallback(
+ (id: string, isOpen: boolean) =>
+ setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })),
+ [setOpenTreeNodes],
+ );
+ const getDragLabel = useCallback(
+ (n: SharedPageTreeNode) => n.name || "untitled",
+ [],
+ );
+
+ const renderRow = useCallback(
+ (props: RenderRowProps) => (
+
+ ),
+ [getNodeUrl],
+ );
+
+ if (!treeData?.length) {
+ return null;
+ }
+
+ return (
+
+ readOnly
+ ref={treeRef}
+ data={treeData}
+ openIds={openIds}
+ selectedId={currentNodeId}
+ renderRow={renderRow}
+ indentPerLevel={INDENT_PER_LEVEL}
+ rowHeight={36}
+ dynamicRowHeight
+ rowClassName={styles.treeNodeChrome}
+ onMove={noopMove}
+ onToggle={handleToggle}
+ getDragLabel={getDragLabel}
+ aria-label={t("Pages")}
+ />
+ );
+}
+
+// Module-scope noop so it's a stable reference across renders.
+const noopMove = () => {};
+
+const INDENT_PER_LEVEL = 16;
+
+
+type DocsTreeRowProps = RenderRowProps & {
+ getNodeUrl: (node: Pick) => string;
+};
+
+function DocsTreeRow({
+ node,
+ level,
+ isOpen,
+ hasChildren,
+ isSelected,
+ rowRef,
+ tabIndex,
+ treeItemProps,
+ toggleOpen,
+ getNodeUrl,
+}: DocsTreeRowProps) {
+ const { t } = useTranslation();
+ const setMobileSidebarOpen = useSetAtom(docsMobileSidebarAtom);
+
+ return (
+ }
+ tabIndex={tabIndex}
+ {...treeItemProps}
+ data-selected={isSelected || undefined}
+ data-open-parent={(level === 0 && isOpen && hasChildren) || undefined}
+ className={styles.treeRow}
+ to={getNodeUrl(node)}
+ onClick={() => {
+ setMobileSidebarOpen(false);
+ }}
+ >
+ {/* One segment per ancestor level; contiguous rows join into a rail. */}
+ {Array.from({ length: level }, (_, ancestor) => (
+
+ ))}
+ {node.icon && (
+
+ {node.icon}
+
+ )}
+ {node.name || t("untitled")}
+ {hasChildren && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleOpen();
+ }}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx
new file mode 100644
index 000000000..db5c23651
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx
@@ -0,0 +1,27 @@
+import { createContext, useContext } from "react";
+import { SharedPageTreeNode } from "@/features/share/utils.ts";
+
+// What varies between the public surfaces (/docs and /share) rendered by the
+// docs shell; every shell component reads this contract instead of a feature.
+export type DocsSurface = {
+ treeData: SharedPageTreeNode[] | null;
+ hasSidebar: boolean;
+ siteName?: string;
+ homeUrl?: string;
+ getNodeUrl: (node: Pick) => string;
+ showBranding: boolean;
+ showEditPage: boolean;
+ brandingRef?: string;
+};
+
+const DocsSurfaceContext = createContext(null);
+
+export const DocsSurfaceProvider = DocsSurfaceContext.Provider;
+
+export function useDocsSurface(): DocsSurface {
+ const surface = useContext(DocsSurfaceContext);
+ if (!surface) {
+ throw new Error("useDocsSurface must be used within DocsShell");
+ }
+ return surface;
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx
new file mode 100644
index 000000000..fdc8d6523
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx
@@ -0,0 +1,35 @@
+import {
+ ActionIcon,
+ Tooltip,
+ useComputedColorScheme,
+ useMantineColorScheme,
+} from "@mantine/core";
+import { IconMoon, IconSun } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import styles from "./docs.module.css";
+
+export default function DocsThemeToggle() {
+ const { t } = useTranslation();
+ const { setColorScheme } = useMantineColorScheme();
+ const computedColorScheme = useComputedColorScheme("light");
+
+ return (
+
+
+ setColorScheme(computedColorScheme === "light" ? "dark" : "light")
+ }
+ aria-label={t("Toggle color scheme")}
+ >
+ {computedColorScheme === "light" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs-toc.tsx b/apps/client/src/features/public-space/components/docs/docs-toc.tsx
new file mode 100644
index 000000000..ceb43641f
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs-toc.tsx
@@ -0,0 +1,116 @@
+import { useCallback, useEffect, useState } from "react";
+import { TextSelection } from "@tiptap/pm/state";
+import { useAtomValue } from "jotai";
+import { useTranslation } from "react-i18next";
+import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
+import {
+ HeadingLink,
+ recalculateLinks,
+} from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
+import styles from "./docs.module.css";
+
+function getHeaderOffset(): number {
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(
+ "--docs-header-h",
+ );
+ const parsed = parseInt(raw, 10);
+ return Number.isNaN(parsed) ? 56 : parsed;
+}
+
+export default function DocsToc() {
+ const { t } = useTranslation();
+ const editor = useAtomValue(readOnlyEditorAtom);
+ const [links, setLinks] = useState([]);
+ const [headingDOMNodes, setHeadingDOMNodes] = useState([]);
+ const [activeElement, setActiveElement] = useState(null);
+
+ const handleUpdate = useCallback(() => {
+ if (!editor || editor.isDestroyed) return;
+ const result = recalculateLinks(editor.$nodes("heading"));
+ setLinks(result.links);
+ setHeadingDOMNodes(result.nodes);
+ }, [editor]);
+
+ useEffect(() => {
+ // "create" repopulates once the editor view mounts after this component.
+ editor?.on("create", handleUpdate);
+ editor?.on("update", handleUpdate);
+ handleUpdate();
+
+ return () => {
+ editor?.off("create", handleUpdate);
+ editor?.off("update", handleUpdate);
+ };
+ }, [editor, handleUpdate]);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setActiveElement(entry.target as HTMLElement);
+ }
+ });
+ },
+ {
+ rootMargin: `-${getHeaderOffset()}px 0px -85% 0px`,
+ threshold: 0,
+ root: null,
+ },
+ );
+
+ headingDOMNodes.forEach((heading) => observer.observe(heading));
+ return () => {
+ headingDOMNodes.forEach((heading) => observer.unobserve(heading));
+ };
+ }, [headingDOMNodes]);
+
+ const handleScrollToHeading = (position: number) => {
+ if (!editor || editor.isDestroyed) return;
+ const { view } = editor;
+
+ const { node } = view.domAtPos(position);
+ const element = node as HTMLElement;
+ const scrollPosition =
+ element.getBoundingClientRect().top +
+ window.scrollY -
+ getHeaderOffset() -
+ 16;
+
+ window.scrollTo({ top: scrollPosition, behavior: "smooth" });
+
+ const tr = view.state.tr;
+ tr.setSelection(new TextSelection(tr.doc.resolve(position)));
+ view.dispatch(tr);
+ view.focus();
+ };
+
+ if (!links.length) {
+ return null;
+ }
+
+ const minLevel = Math.min(...links.map((link) => link.level));
+ const effectiveActive = activeElement ?? links[0]?.element;
+
+ return (
+
+
{t("On this page")}
+
+ {links.map((item, idx) => (
+ handleScrollToHeading(item.position)}
+ >
+ {item.label}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/docs/docs.module.css b/apps/client/src/features/public-space/components/docs/docs.module.css
new file mode 100644
index 000000000..6f008a429
--- /dev/null
+++ b/apps/client/src/features/public-space/components/docs/docs.module.css
@@ -0,0 +1,902 @@
+/* Design tokens for the public docs surface. Declared on :root (not .root)
+ * because mobile drawers and the spotlight render in portals outside the shell
+ * subtree. The accent pair is overridden at runtime by docs-theme.ts. */
+/* light-dark() cannot live in a bare :root block (the transform emits a
+ * descendant selector that never matches :root), so dark values get their own
+ * attribute-qualified block; flipping semantics ride Mantine's own vars. */
+:root {
+ --docs-header-h: 56px;
+ --docs-sidebar-w: 280px;
+ --docs-toc-w: 240px;
+ --docs-site-max: 96rem;
+ --docs-content-max: 54rem;
+ --docs-radius: 6px;
+
+ --docs-accent: #2b7af1;
+ --docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent);
+
+ /* Cloudflare-style single-ink model: one foreground for headings, bold, and
+ * body on a just-off-white page; neither end of the scale is pure. */
+ --docs-bg: oklch(99% 0 0);
+ --docs-fg: oklch(21% 0 0);
+ --docs-content-fg: var(--docs-fg);
+ --docs-nav-fg: oklch(47% 0 0);
+ --docs-muted: var(--mantine-color-dimmed);
+ --docs-hover: var(--mantine-color-default-hover);
+ --docs-faint: var(--mantine-color-gray-6);
+ --docs-border: var(--mantine-color-gray-2);
+ --docs-header-bg: color-mix(in srgb, var(--docs-bg) 78%, transparent);
+}
+
+:root[data-mantine-color-scheme="dark"] {
+ --docs-bg: var(--mantine-color-body);
+ --docs-fg: oklch(90% 0 0);
+ --docs-nav-fg: oklch(72% 0 0);
+ --docs-faint: var(--mantine-color-dark-2);
+ --docs-border: var(--mantine-color-dark-5);
+}
+
+.root {
+ min-height: 100dvh;
+ background-color: var(--docs-bg);
+ color: var(--docs-fg);
+}
+
+/* ---------- Header ---------- */
+
+.header {
+ position: sticky;
+ top: 0;
+ z-index: 90;
+ height: var(--docs-header-h);
+ padding-inline: rem(20px);
+ background-color: var(--docs-header-bg);
+ backdrop-filter: saturate(180%) blur(10px);
+ -webkit-backdrop-filter: saturate(180%) blur(10px);
+ border-bottom: 1px solid var(--docs-border);
+}
+
+.headerInner {
+ max-width: var(--docs-site-max);
+ margin-inline: auto;
+ height: 100%;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+ gap: rem(16px);
+}
+
+.headerLeft {
+ display: flex;
+ align-items: center;
+ gap: rem(10px);
+ min-width: 0;
+}
+
+.headerCenter {
+ display: flex;
+ justify-content: center;
+ min-width: 0;
+}
+
+.headerRight {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: rem(4px);
+}
+
+.searchSlot {
+ @media (max-width: $mantine-breakpoint-sm) {
+ display: none;
+ }
+}
+
+.mobileOnly {
+ display: inline-flex;
+
+ @media (min-width: $mantine-breakpoint-sm) {
+ display: none;
+ }
+}
+
+/* Plain space name in the header, used only when there is no sidebar to
+ * carry it. The brand slot is reserved for future org logo/name support. */
+.headerSpaceName {
+ font-size: rem(14.5px);
+ font-weight: 600;
+ letter-spacing: -0.011em;
+ color: var(--docs-fg);
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border-radius: var(--docs-radius);
+ padding: rem(4px) rem(6px);
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: 1px;
+ }
+}
+
+/* Burger appears only once the sidebar column is collapsed. */
+@media (min-width: 64em) {
+ .sidebarToggle {
+ display: none !important;
+ }
+}
+
+.headerAction {
+ color: var(--docs-faint);
+ border-radius: var(--docs-radius);
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-fg);
+ background-color: var(--docs-hover);
+ }
+ }
+}
+
+/* ---------- Search ---------- */
+
+.searchButton {
+ display: flex;
+ align-items: center;
+ gap: rem(8px);
+ width: rem(320px);
+ height: rem(34px);
+ padding-inline: rem(10px);
+ border: 1px solid var(--docs-border);
+ border-radius: rem(8px);
+ background-color: var(--docs-bg);
+ color: var(--docs-muted);
+ font-size: rem(13px);
+ cursor: pointer;
+ transition: border-color 120ms ease;
+
+ @media (hover: hover) {
+ &:hover {
+ border-color: light-dark(
+ var(--mantine-color-gray-4),
+ var(--mantine-color-dark-3)
+ );
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: 1px;
+ }
+
+ @media (max-width: 62em) {
+ width: rem(220px);
+ }
+}
+
+.searchLabel {
+ flex: 1;
+ text-align: left;
+}
+
+.searchKbd {
+ font-size: rem(11px);
+ font-weight: 500;
+ color: var(--docs-faint);
+ border: 1px solid var(--docs-border);
+ border-radius: rem(4px);
+ padding: rem(1px) rem(5px);
+ line-height: 1.4;
+}
+
+/* Quiet bordered chip in the docs palette instead of Mantine's full-ink default. */
+.copyPageButton {
+ color: var(--docs-muted);
+ border-color: var(--docs-border);
+ background-color: transparent;
+ font-size: rem(13px);
+ font-weight: 500;
+ border-radius: var(--docs-radius);
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-fg);
+ background-color: var(--docs-hover);
+ }
+ }
+}
+
+/* Page actions pinned to the article's top-right corner; on small viewports
+ * they drop into flow above the content so breadcrumbs never run under them. */
+.articleActions {
+ position: absolute;
+ top: rem(30px);
+ right: rem(24px);
+ display: inline-flex;
+ align-items: center;
+ gap: rem(6px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ position: static;
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: rem(4px);
+ }
+}
+
+/* Below the rail breakpoint the toc opens as a drawer overlay instead. */
+.tocOverlayControl {
+ display: inline-flex;
+
+ @media (min-width: 75em) {
+ display: none;
+ }
+}
+
+/* ---------- Body grid ---------- */
+
+/* Contained site layout: rails anchor the edges of a centered max-width
+ * container, the article centers itself in the fixed middle track. Hiding a
+ * rail keeps its track, so toggling never moves the content column. */
+.body {
+ max-width: var(--docs-site-max);
+ margin-inline: auto;
+ display: grid;
+ grid-template-columns:
+ var(--docs-sidebar-w)
+ minmax(0, 1fr)
+ var(--docs-toc-w);
+ align-items: start;
+}
+
+.sidebar {
+ grid-column: 1;
+ width: var(--docs-sidebar-w);
+ position: sticky;
+ top: var(--docs-header-h);
+ height: calc(100dvh - var(--docs-header-h));
+ display: flex;
+ flex-direction: column;
+ padding: rem(20px) rem(10px) rem(16px) rem(20px);
+ opacity: 1;
+ transform: translateX(0);
+ transition:
+ opacity 160ms ease,
+ transform 160ms ease;
+}
+
+.sidebar[data-hidden="true"] {
+ visibility: hidden;
+ opacity: 0;
+ transform: translateX(rem(-8px));
+}
+
+.sidebarTitle {
+ display: block;
+ font-size: rem(14px);
+ font-weight: 600;
+ letter-spacing: -0.011em;
+ color: var(--docs-fg);
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ border-radius: var(--docs-radius);
+ padding: rem(5px) rem(8px);
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-accent);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: -2px;
+ }
+}
+
+.sidebarDivider {
+ height: 1px;
+ background-color: var(--docs-border);
+ margin: rem(10px) 0 rem(14px);
+ flex-shrink: 0;
+}
+
+.sidebarScroll {
+ flex: 1;
+ min-height: 0;
+}
+
+.main {
+ grid-column: 2;
+ min-width: 0;
+}
+
+.article {
+ max-width: var(--docs-content-max);
+ margin-inline: auto;
+ padding: rem(36px) rem(32px) rem(72px);
+ position: relative;
+}
+
+.toc {
+ grid-column: 3;
+ width: var(--docs-toc-w);
+ position: sticky;
+ top: var(--docs-header-h);
+ max-height: calc(100dvh - var(--docs-header-h));
+ overflow-y: auto;
+ scrollbar-width: thin;
+ padding: rem(36px) rem(16px) rem(24px) rem(4px);
+}
+
+@media (max-width: 75em) {
+ .body {
+ grid-template-columns: var(--docs-sidebar-w) minmax(0, 1fr);
+ }
+
+ .toc {
+ display: none;
+ }
+}
+
+/* Below 64em the sidebar collapses into the burger drawer. */
+@media (max-width: 64em) {
+ .body {
+ display: block;
+ }
+
+ .sidebar {
+ display: none;
+ }
+}
+
+@media (max-width: $mantine-breakpoint-sm) {
+ .article {
+ padding: rem(24px) rem(20px) rem(56px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .body,
+ .sidebar,
+ .toc,
+ .searchButton {
+ transition: none;
+ }
+}
+
+/* ---------- Sidebar tree ---------- */
+
+/* Applied through DocTree's rowClassName onto the engine wrapper that carries
+ * data-selected. Class doubled to outrank the engine's own module styles. */
+.treeNodeChrome.treeNodeChrome {
+ border-radius: var(--docs-radius);
+ color: var(--docs-nav-fg);
+
+ @media (hover: hover) {
+ &:hover {
+ background-color: var(--docs-hover);
+ color: var(--docs-fg);
+ }
+ }
+}
+
+.treeNodeChrome.treeNodeChrome[data-selected="true"] {
+ background-color: var(--docs-accent-soft);
+}
+
+.treeRow {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: rem(8px);
+ width: 100%;
+ min-width: 0;
+ min-height: rem(32px);
+ padding: rem(6px) rem(4px) rem(6px) rem(8px);
+ text-decoration: none;
+ color: inherit;
+ font-size: rem(14px);
+ font-weight: 400;
+ line-height: 1.45;
+ border-radius: var(--docs-radius);
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: -2px;
+ }
+}
+
+/* Expanded parents read as section headers, Cloudflare-style. */
+.treeRow[data-open-parent="true"] {
+ color: var(--docs-fg);
+ font-weight: 500;
+}
+
+.treeRow[data-selected="true"] {
+ color: var(--docs-accent);
+ font-weight: 500;
+}
+
+.treeIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: rem(18px);
+ font-size: rem(14px);
+ line-height: 1;
+ flex-shrink: 0;
+ margin-top: rem(2px);
+}
+
+/* Names wrap instead of truncating: the sidebar has a fixed width, so an
+ * ellipsis would permanently hide the tail of long titles. */
+.treeText {
+ flex: 1;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+/* Nesting rail: each row draws its ancestors' segments in the indent gutter;
+ * the -2px bleed covers the engine's row padding so segments connect. */
+.treeGuide {
+ position: absolute;
+ top: 0;
+ bottom: rem(-2px);
+ width: 1px;
+ background-color: var(--docs-border);
+ pointer-events: none;
+}
+
+.treeChevron {
+ flex-shrink: 0;
+ color: var(--docs-faint);
+ transition: transform 140ms ease;
+}
+
+.treeChevron[data-open="true"] {
+ transform: rotate(90deg);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .treeChevron {
+ transition: none;
+ }
+}
+
+/* ---------- Table of contents ---------- */
+
+.tocLabel {
+ display: block;
+ font-size: rem(11px);
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--docs-muted);
+ margin-bottom: rem(10px);
+}
+
+.tocList {
+ border-left: 1px solid var(--docs-border);
+}
+
+.tocLink {
+ display: block;
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: 0;
+ border-left: 2px solid transparent;
+ margin-left: -1px;
+ padding: rem(4px) rem(8px) rem(4px) rem(11px);
+ font-size: rem(13px);
+ line-height: 1.45;
+ color: var(--docs-muted);
+ cursor: pointer;
+ overflow-wrap: break-word;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-fg);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: -2px;
+ border-radius: rem(2px);
+ }
+}
+
+.tocLink[data-active="true"] {
+ color: var(--docs-accent);
+ border-left-color: var(--docs-accent);
+ font-weight: 500;
+}
+
+/* ---------- Edit page (signed-in visitors) ---------- */
+
+.editPageLink {
+ display: flex;
+ align-items: center;
+ gap: rem(6px);
+ margin-top: rem(16px);
+ padding-top: rem(14px);
+ border-top: 1px solid var(--docs-border);
+ font-size: rem(13px);
+ color: var(--docs-muted);
+ text-decoration: none;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-fg);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: 2px;
+ border-radius: rem(2px);
+ }
+}
+
+/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */
+
+/* ---------- Footer branding ---------- */
+
+.footer {
+ margin-top: rem(40px);
+ padding-top: rem(16px);
+ border-top: 1px solid var(--docs-border);
+}
+
+.footerBranding {
+ font-size: rem(14px);
+ color: var(--docs-muted);
+ text-decoration: none;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-fg);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: 2px;
+ border-radius: rem(2px);
+ }
+}
+
+/* ---------- Breadcrumbs ---------- */
+
+.breadcrumbs {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: rem(4px);
+ font-size: rem(13px);
+ color: var(--docs-muted);
+ margin-bottom: rem(6px);
+ /* keep long trails clear of the pinned page actions */
+ padding-right: rem(160px);
+
+ @media (max-width: $mantine-breakpoint-sm) {
+ padding-right: 0;
+ }
+}
+
+.crumbLink {
+ color: var(--docs-muted);
+ text-decoration: none;
+ border-radius: rem(4px);
+ padding: rem(1px) rem(3px);
+ max-width: rem(220px);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-accent);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: 0;
+ }
+}
+
+.crumbSeparator {
+ color: light-dark(
+ var(--mantine-color-gray-4),
+ var(--mantine-color-dark-3)
+ );
+ user-select: none;
+}
+
+.crumbEllipsis {
+ color: var(--docs-muted);
+ border: 0;
+ background: none;
+ cursor: pointer;
+ border-radius: rem(4px);
+ padding: rem(1px) rem(4px);
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--docs-accent);
+ background-color: var(--docs-hover);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: 0;
+ }
+}
+
+/* ---------- Byline ---------- */
+
+/* Pulled up into the title's own bottom margin so it reads as one block. */
+.byline {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: rem(8px);
+ margin-top: rem(-16px);
+ margin-bottom: rem(28px);
+ font-size: rem(13px);
+ color: var(--docs-muted);
+}
+
+.bylineAuthor {
+ display: inline-flex;
+ align-items: center;
+ gap: rem(6px);
+}
+
+.bylineDot {
+ color: var(--docs-faint);
+ user-select: none;
+}
+
+/* ---------- Prev / next ---------- */
+
+.pageNav {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: rem(12px);
+ margin-top: rem(48px);
+}
+
+.pageNavCard {
+ display: flex;
+ flex-direction: column;
+ gap: rem(4px);
+ padding: rem(12px) rem(16px);
+ border: 1px solid var(--docs-border);
+ border-radius: rem(10px);
+ text-decoration: none;
+ min-width: 0;
+ transition: border-color 120ms ease;
+
+ @media (hover: hover) {
+ &:hover {
+ border-color: var(--docs-accent);
+ }
+
+ &:hover .pageNavTitle {
+ color: var(--docs-accent);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--docs-accent);
+ outline-offset: 1px;
+ }
+}
+
+.pageNavCard[data-direction="next"] {
+ grid-column: 2;
+ align-items: flex-end;
+ text-align: right;
+}
+
+.pageNavLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: rem(4px);
+ font-size: rem(12px);
+ color: var(--docs-muted);
+}
+
+.pageNavTitle {
+ font-size: rem(14px);
+ font-weight: 500;
+ color: var(--docs-fg);
+ max-width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ transition: color 120ms ease;
+}
+
+@media (max-width: $mantine-breakpoint-sm) {
+ .pageNav {
+ grid-template-columns: 1fr;
+ }
+
+ .pageNavCard[data-direction="next"] {
+ grid-column: auto;
+ }
+}
+
+/* ---------- Content ---------- */
+
+/* Reading typography: body copy softens to a blue-gray with relaxed leading
+ * while headings and bold keep full contrast. Class doubled to outrank the
+ * shared .public-typography metrics regardless of stylesheet order. */
+.root.root :global(.ProseMirror) {
+ color: var(--docs-content-fg);
+ line-height: 1.75;
+ letter-spacing: normal;
+ /* The article owns horizontal spacing here; the editor's 3rem gutter would
+ * misalign content with the breadcrumb, and its own background bands
+ * against the off-white page. */
+ padding-left: 0;
+ padding-right: 0;
+ background-color: transparent;
+}
+
+/* Wide columns bleed into the editor gutter that no longer exists here. */
+.root.root :global(div[data-type="columns"][data-width-mode="wide"]) {
+ margin-left: 0;
+ margin-right: 0;
+ width: 100%;
+}
+
+/* One ink by inheritance: Mantine's baseline gives headings an explicit
+ * color, which forks them from the body. Neutralize instead of re-declaring,
+ * so changing the single content foreground repaints all text. */
+.root.root :global(.ProseMirror) h1,
+.root.root :global(.ProseMirror) h2,
+.root.root :global(.ProseMirror) h3,
+.root.root :global(.ProseMirror) h4,
+.root.root :global(.ProseMirror) h5,
+.root.root :global(.ProseMirror) h6,
+.root.root :global(.ProseMirror) strong {
+ color: inherit;
+}
+
+/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank
+ * the shared editor and .public-typography rules. */
+.root.root :global(.ProseMirror) h1 {
+ font-size: 2.1875rem;
+ font-weight: 600;
+ letter-spacing: -0.025em;
+ line-height: 1.25;
+}
+
+.root.root :global(.ProseMirror) h2 {
+ font-size: 1.3rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1.4;
+}
+
+.root.root :global(.ProseMirror) h3 {
+ font-size: 1.1rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ line-height: 1.45;
+}
+
+.root.root :global(.ProseMirror) h4,
+.root.root :global(.ProseMirror) h5,
+.root.root :global(.ProseMirror) h6 {
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.5;
+}
+
+.root.root :global(.ProseMirror) strong {
+ font-weight: 600;
+}
+
+.root.root :global(.page-title .ProseMirror) h1 {
+ font-size: 2.1875rem;
+ font-weight: 600;
+ letter-spacing: -0.025em;
+ line-height: 1.25;
+}
+
+.root :global(.ProseMirror) a {
+ color: var(--docs-accent);
+}
+
+/* Internal page links (mentions, subpages lists) follow the accent like any
+ * other link; the app skin pins them to ink with !important, hence the
+ * counter-!important, and the underline tint softens to match. */
+.root :global(.ProseMirror) a[class*="pageMentionLink"] {
+ color: var(--docs-accent) !important;
+}
+
+.root :global(.ProseMirror) [class*="pageMentionText"] {
+ border-bottom-color: color-mix(in srgb, var(--docs-accent) 40%, transparent);
+}
+
+.root :global(.ProseMirror) h1,
+.root :global(.ProseMirror) h2,
+.root :global(.ProseMirror) h3,
+.root :global(.ProseMirror) h4 {
+ scroll-margin-top: calc(var(--docs-header-h) + rem(16px));
+}
+
+/* ---------- Content tables ---------- */
+
+/* The wrapper carries the rounded outer border so border-collapse never
+ * fights border-radius; its overflow-x clips the corners. */
+.root.root :global(.ProseMirror .tableWrapper) {
+ border: 1px solid var(--docs-border);
+ border-radius: rem(10px);
+}
+
+.root.root :global(.ProseMirror table td),
+.root.root :global(.ProseMirror table th) {
+ border: 0;
+ border-bottom: 1px solid var(--docs-border);
+ border-right: 1px solid var(--docs-border);
+ padding: rem(10px) rem(14px);
+}
+
+.root.root :global(.ProseMirror table :is(td, th):last-child) {
+ border-right: 0;
+}
+
+.root.root :global(.ProseMirror table tr:last-child td),
+.root.root :global(.ProseMirror table tr:last-child th) {
+ border-bottom: 0;
+}
+
+.root.root :global(.ProseMirror table th) {
+ background-color: var(--docs-hover);
+ color: var(--docs-fg);
+ font-weight: 600;
+}
+
+/* Round the corner cells too for the no-overflow (pinned header) variant,
+ * where the wrapper does not clip. */
+.root.root :global(.ProseMirror table tr:first-child :is(th, td):first-child) {
+ border-top-left-radius: rem(9px);
+}
+
+.root.root :global(.ProseMirror table tr:first-child :is(th, td):last-child) {
+ border-top-right-radius: rem(9px);
+}
+
+.root.root :global(.ProseMirror table tr:last-child :is(th, td):first-child) {
+ border-bottom-left-radius: rem(9px);
+}
+
+.root.root :global(.ProseMirror table tr:last-child :is(th, td):last-child) {
+ border-bottom-right-radius: rem(9px);
+}
+
+.emptyState {
+ padding-top: rem(96px);
+ text-align: center;
+ color: var(--docs-muted);
+}
+
+/* ---------- Mobile drawers ---------- */
+
+.drawerTree {
+ height: calc(100dvh - rem(60px));
+ display: flex;
+ flex-direction: column;
+}
diff --git a/apps/client/src/features/public-space/components/public-space-layout.tsx b/apps/client/src/features/public-space/components/public-space-layout.tsx
new file mode 100644
index 000000000..2bd01b20a
--- /dev/null
+++ b/apps/client/src/features/public-space/components/public-space-layout.tsx
@@ -0,0 +1,69 @@
+import "@fontsource-variable/inter";
+import "@/styles/public-typography.css";
+import { useEffect, useMemo } from "react";
+import { Outlet, useParams } from "react-router-dom";
+import { useSetAtom } from "jotai";
+import { usePublicSpaceTreeQuery } from "@/features/public-space/queries/public-space-query.ts";
+import { buildSharedPageTree } from "@/features/share/utils.ts";
+import {
+ publicSpaceTreeAtom,
+ publicSpaceTreeDataAtom,
+} from "@/features/public-space/atoms/public-space-atoms.ts";
+import { useDocsAccent } from "@/features/public-space/theme/docs-theme.ts";
+import DocsShell from "@/features/public-space/components/docs/docs-shell.tsx";
+import { DocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
+import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
+import { PublicSpaceSearchSpotlight } from "@/features/search/components/public-space-search-spotlight.tsx";
+import { publicSpaceSearchSpotlight } from "@/features/search/constants";
+
+export default function PublicSpaceLayout() {
+ const { spaceSlug } = useParams();
+ const { data } = usePublicSpaceTreeQuery(spaceSlug);
+
+ useDocsAccent(data?.appearance);
+
+ const setPublicSpaceTree = useSetAtom(publicSpaceTreeAtom);
+ const setPublicSpaceTreeData = useSetAtom(publicSpaceTreeDataAtom);
+
+ const treeData = useMemo(() => {
+ if (!data?.pageTree) return null;
+ return buildSharedPageTree(data.pageTree);
+ }, [data?.pageTree]);
+
+ useEffect(() => {
+ setPublicSpaceTree(data || null);
+ setPublicSpaceTreeData(treeData);
+ }, [data, treeData, setPublicSpaceTree, setPublicSpaceTreeData]);
+
+ const surface = useMemo(() => {
+ const homeUrl = buildPublicSpaceUrl({ spaceSlug });
+ // The first root page is the space home, served at the bare space URL.
+ const firstRootSlugId = treeData?.[0]?.slugId;
+ return {
+ treeData,
+ hasSidebar: (data?.pageTree?.length ?? 0) > 1,
+ siteName: data?.space?.name,
+ homeUrl,
+ getNodeUrl: (node) =>
+ node.slugId === firstRootSlugId
+ ? homeUrl
+ : buildPublicSpaceUrl({
+ spaceSlug,
+ pageSlugId: node.slugId,
+ pageTitle: node.name,
+ }),
+ showBranding: Boolean(data),
+ showEditPage: true,
+ };
+ }, [data, treeData, spaceSlug]);
+
+ return (
+ }
+ >
+
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/publish-space-settings.tsx b/apps/client/src/features/public-space/components/publish-space-settings.tsx
new file mode 100644
index 000000000..332ebd373
--- /dev/null
+++ b/apps/client/src/features/public-space/components/publish-space-settings.tsx
@@ -0,0 +1,283 @@
+import { ActionIcon, Group, Text, Switch, TextInput } from "@mantine/core";
+import { modals } from "@mantine/modals";
+import { useAtom } from "jotai";
+import React, { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { IconExternalLink, IconWorld } from "@tabler/icons-react";
+import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
+import { ISpace } from "@/features/space/types/space.types.ts";
+import { IPublicSpace } from "@/features/public-space/types/public-space.types.ts";
+import {
+ usePublicSpaceForSpaceQuery,
+ usePublishSpaceMutation,
+} from "@/features/public-space/queries/public-space-query.ts";
+import { getAppUrl } from "@/lib/config.ts";
+import CopyTextButton from "@/components/common/copy.tsx";
+import AppearanceSettings from "@/features/public-space/components/appearance-settings.tsx";
+import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts";
+
+type PublishSpaceSettingsProps = {
+ space: ISpace;
+};
+
+export default function PublishSpaceSettings({
+ space,
+}: PublishSpaceSettingsProps) {
+ const { t } = useTranslation();
+ const [workspace] = useAtom(workspaceAtom);
+
+ const allowPublicSpaces = isPublicSpacesAllowed(workspace);
+
+ const { data: publicSpace } = usePublicSpaceForSpaceQuery(
+ allowPublicSpaces ? space?.id : undefined,
+ );
+ const publishMutation = usePublishSpaceMutation();
+
+ const [published, setPublished] = useState(false);
+ const [searchIndexing, setSearchIndexing] = useState(false);
+ const [bylineAuthor, setBylineAuthor] = useState(false);
+ const [bylineUpdatedAt, setBylineUpdatedAt] = useState(true);
+ const [directoryListed, setDirectoryListed] = useState(false);
+
+ const workspaceDirectoryEnabled =
+ workspace?.settings?.publicSpaces?.directory === true;
+
+ const syncFromPublicSpace = (state?: IPublicSpace | null) => {
+ const byline = state?.settings?.byline;
+ setPublished(state?.enabled === true);
+ setSearchIndexing(state?.searchIndexing === true);
+ setBylineAuthor(byline?.author === true);
+ setBylineUpdatedAt(byline?.updatedAt !== false);
+ setDirectoryListed(state?.settings?.directory === true);
+ };
+
+ useEffect(() => {
+ syncFromPublicSpace(publicSpace);
+ }, [publicSpace]);
+
+ if (!allowPublicSpaces || !space) {
+ return null;
+ }
+
+ const publicUrl = `${getAppUrl()}/docs/${space.slug}`;
+
+ const applyPublish = async (enabled: boolean) => {
+ try {
+ const result = await publishMutation.mutateAsync({
+ spaceId: space.id,
+ enabled,
+ });
+ syncFromPublicSpace(result);
+ } catch {
+ // error handled by mutation
+ }
+ };
+
+ const handlePublishChange = (event: React.ChangeEvent) => {
+ const value = event.currentTarget.checked;
+ if (!value) {
+ applyPublish(false);
+ return;
+ }
+
+ modals.openConfirmModal({
+ title: t("Publish space to the web"),
+ children: (
+
+ {t(
+ "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?",
+ )}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Publish"), cancel: t("Cancel") },
+ onConfirm: () => applyPublish(true),
+ });
+ };
+
+ const handleIndexingChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ const value = event.currentTarget.checked;
+ try {
+ await publishMutation.mutateAsync({
+ spaceId: space.id,
+ enabled: true,
+ searchIndexing: value,
+ });
+ setSearchIndexing(value);
+ } catch {
+ // error handled by mutation
+ }
+ };
+
+ const handleBylineAuthorChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ const value = event.currentTarget.checked;
+ try {
+ await publishMutation.mutateAsync({
+ spaceId: space.id,
+ enabled: true,
+ bylineAuthor: value,
+ });
+ setBylineAuthor(value);
+ } catch {
+ // error handled by mutation
+ }
+ };
+
+ const handleBylineUpdatedAtChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ const value = event.currentTarget.checked;
+ try {
+ await publishMutation.mutateAsync({
+ spaceId: space.id,
+ enabled: true,
+ bylineUpdatedAt: value,
+ });
+ setBylineUpdatedAt(value);
+ } catch {
+ // error handled by mutation
+ }
+ };
+
+ const handleDirectoryChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ const value = event.currentTarget.checked;
+ try {
+ await publishMutation.mutateAsync({
+ spaceId: space.id,
+ enabled: true,
+ directory: value,
+ });
+ setDirectoryListed(value);
+ } catch {
+ // error handled by mutation
+ }
+ };
+
+ return (
+
+
+
+ {t("Publish space to the web")}
+
+ {t("Make this space publicly readable by anyone on the internet.")}
+
+
+
+
+
+ {published && (
+ <>
+
+
+ {t("Allow search engines to index")}
+
+ {t(
+ "Let public pages in this space appear in search engine results.",
+ )}
+
+
+
+
+
+
+
+ {t("Show page author")}
+
+ {t("Display the page creator's name on public pages.")}
+
+
+
+
+
+
+
+ {t("Show last updated")}
+
+ {t("Display when each page was last updated.")}
+
+
+
+
+
+ {workspaceDirectoryEnabled && (
+
+
+ {t("Show in public directory")}
+
+ {t("List this space in the public directory at /docs.")}
+
+
+
+
+ )}
+
+
+ }
+ aria-label={t("Public space link")}
+ rightSection={
+
+ }
+ />
+
+
+
+
+
+
+ {t("Renaming the space slug will break public links.")}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/published-spaces-list.tsx b/apps/client/src/features/public-space/components/published-spaces-list.tsx
new file mode 100644
index 000000000..a4aec36a0
--- /dev/null
+++ b/apps/client/src/features/public-space/components/published-spaces-list.tsx
@@ -0,0 +1,210 @@
+import { Table, Group, Text, Anchor, Menu, ActionIcon } from "@mantine/core";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+import { modals } from "@mantine/modals";
+import { notifications } from "@mantine/notifications";
+import {
+ IconCopy,
+ IconDots,
+ IconExternalLink,
+ IconWorld,
+ IconWorldOff,
+} from "@tabler/icons-react";
+import Paginate from "@/components/common/paginate.tsx";
+import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
+import {
+ usePublishedSpacesQuery,
+ usePublishSpaceMutation,
+} from "@/features/public-space/queries/public-space-query.ts";
+import { IPublishedSpaceItem } from "@/features/public-space/types/public-space.types.ts";
+import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
+import { getAppUrl, getSpaceUrl } from "@/lib/config.ts";
+import { useClipboard } from "@/hooks/use-clipboard";
+import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
+import { EmptyState } from "@/components/ui/empty-state.tsx";
+import rowClasses from "@/components/ui/clickable-table-row.module.css";
+
+export default function PublishedSpacesList() {
+ const { t } = useTranslation();
+ const { cursor, goNext, goPrev } = useCursorPaginate();
+ const { data, isLoading } = usePublishedSpacesQuery({ cursor });
+ const locale = useDateFnsLocale();
+
+ if (!isLoading && data?.items.length === 0) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+ {t("Space")}
+ {t("Published by")}
+ {t("Published at")}
+
+
+
+
+ {data?.items.map((item: IPublishedSpaceItem) => (
+
+
+
+
+
+
+ {item.space.name}
+
+
+
+
+
+
+
+
+ {item.creator?.name}
+
+
+
+
+
+ {formatLocalized(
+ item.createdAt,
+ "MMM dd, yyyy",
+ "PP",
+ locale,
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {data?.items.length > 0 && (
+ goNext(data?.meta?.nextCursor)}
+ onPrev={goPrev}
+ />
+ )}
+ >
+ );
+}
+
+function PublishedSpaceActionMenu({ item }: { item: IPublishedSpaceItem }) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const clipboard = useClipboard();
+ const publishMutation = usePublishSpaceMutation();
+
+ const publicPath = buildPublicSpaceUrl({ spaceSlug: item.space.slug });
+
+ const copyLink = () => {
+ clipboard.copy(`${getAppUrl()}${publicPath}`);
+ notifications.show({ message: t("Link copied") });
+ };
+
+ const onUnpublish = async () => {
+ try {
+ await publishMutation.mutateAsync({
+ spaceId: item.spaceId,
+ enabled: false,
+ });
+ } catch {
+ // error handled by mutation
+ }
+ };
+
+ const openUnpublishModal = () =>
+ modals.openConfirmModal({
+ title: t("Unpublish space"),
+ children: (
+
+ {t(
+ "This space will no longer be publicly accessible. Are you sure?",
+ )}
+
+ ),
+ centered: true,
+ labels: { confirm: t("Unpublish"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: onUnpublish,
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ }>
+ {t("Copy link")}
+
+
+ navigate(getSpaceUrl(item.space.slug))}
+ leftSection={ }
+ >
+ {t("Open space")}
+
+
+ }
+ disabled={item.space?.userRole !== "admin"}
+ >
+ {t("Unpublish")}
+
+
+
+ );
+}
diff --git a/apps/client/src/features/public-space/components/space-public-notice.tsx b/apps/client/src/features/public-space/components/space-public-notice.tsx
new file mode 100644
index 000000000..5655995f0
--- /dev/null
+++ b/apps/client/src/features/public-space/components/space-public-notice.tsx
@@ -0,0 +1,47 @@
+import { Alert, Anchor, Group, Text } from "@mantine/core";
+import { IconExternalLink, IconWorld } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { ISpace } from "@/features/space/types/space.types.ts";
+import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
+import { isBetaPublicSpaces } from "@/lib/config.ts";
+
+type SpacePublicNoticeProps = {
+ space: ISpace;
+};
+
+export default function SpacePublicNotice({ space }: SpacePublicNoticeProps) {
+ const { t } = useTranslation();
+
+ if (!isBetaPublicSpaces() || !space?.isPublished) {
+ return null;
+ }
+
+ return (
+ }
+ title={t("This space is public")}
+ mb="lg"
+ >
+
+
+ {t(
+ "Anyone on the internet can read the pages in this space, except restricted pages.",
+ )}
+
+
+ {t("Open public site")}
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/public-space/hooks/use-authenticated-user.ts b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts
new file mode 100644
index 000000000..644619b26
--- /dev/null
+++ b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import { getMyInfo } from "@/features/user/services/user-service";
+import { ICurrentUser } from "@/features/user/types/user.types";
+
+/** Probes login state from public surfaces; the /docs 401 exemption keeps anonymous visitors off the login redirect. */
+export function useAuthenticatedUser(enabled = true) {
+ return useQuery({
+ queryKey: ["currentUser"],
+ queryFn: getMyInfo,
+ retry: false,
+ staleTime: 5 * 60 * 1000,
+ enabled,
+ });
+}
diff --git a/apps/client/src/features/public-space/hooks/use-docs-current-page.ts b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts
new file mode 100644
index 000000000..eb799ee0e
--- /dev/null
+++ b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts
@@ -0,0 +1,23 @@
+import { useMemo } from "react";
+import { useParams } from "react-router-dom";
+import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx";
+import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts";
+import { extractPageSlugId } from "@/lib";
+import { SharedPageTreeNode } from "@/features/share/utils.ts";
+
+export function useDocsCurrentPage(): SharedPageTreeNode | null {
+ const { pageSlug } = useParams();
+ const { treeData } = useDocsSurface();
+
+ return useMemo(() => {
+ if (!treeData?.length) return null;
+ const currentSlugId = pageSlug
+ ? extractPageSlugId(pageSlug)
+ : treeData[0]?.slugId;
+ return (
+ flattenTreePreorder(treeData).find(
+ (node) => node.slugId === currentSlugId,
+ ) ?? null
+ );
+ }, [treeData, pageSlug]);
+}
diff --git a/apps/client/src/features/public-space/queries/public-space-query.ts b/apps/client/src/features/public-space/queries/public-space-query.ts
new file mode 100644
index 000000000..49cba8cc6
--- /dev/null
+++ b/apps/client/src/features/public-space/queries/public-space-query.ts
@@ -0,0 +1,109 @@
+import {
+ keepPreviousData,
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+import {
+ getPublicSpaceDirectory,
+ getPublicSpaceForSpace,
+ getPublicSpacePage,
+ getPublicSpaceTree,
+ getPublishedSpaces,
+ publishSpace,
+} from "@/features/public-space/services/public-space-service.ts";
+import {
+ IPublicSpace,
+ IPublicSpaceDirectory,
+ IPublicSpacePage,
+ IPublicSpaceTree,
+ IPublishedSpaceItem,
+ IPublishSpace,
+} from "@/features/public-space/types/public-space.types.ts";
+import { IPagination, QueryParams } from "@/lib/types.ts";
+
+export function usePublicSpaceTreeQuery(
+ spaceSlug: string,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["public-space-tree", spaceSlug],
+ queryFn: () => getPublicSpaceTree(spaceSlug),
+ enabled: !!spaceSlug,
+ placeholderData: keepPreviousData,
+ staleTime: 60 * 60 * 1000,
+ });
+}
+
+export function usePublicSpacePageQuery(params: {
+ spaceSlug: string;
+ pageSlugId?: string;
+ contentless?: boolean;
+}): UseQueryResult {
+ return useQuery({
+ queryKey: ["public-space-page", params],
+ queryFn: () => getPublicSpacePage(params),
+ enabled: !!params.spaceSlug,
+ });
+}
+
+export function usePublicSpaceDirectoryQuery(): UseQueryResult<
+ IPublicSpaceDirectory,
+ Error
+> {
+ return useQuery({
+ queryKey: ["public-space-directory"],
+ queryFn: () => getPublicSpaceDirectory(),
+ });
+}
+
+export function usePublicSpaceForSpaceQuery(
+ spaceId: string,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["public-space-for-space", spaceId],
+ queryFn: () => getPublicSpaceForSpace(spaceId),
+ enabled: !!spaceId,
+ staleTime: 60 * 1000,
+ retry: false,
+ });
+}
+
+export function usePublishedSpacesQuery(
+ params?: QueryParams,
+): UseQueryResult, Error> {
+ return useQuery({
+ queryKey: ["published-spaces", params],
+ queryFn: () => getPublishedSpaces(params),
+ placeholderData: keepPreviousData,
+ });
+}
+
+export function usePublishSpaceMutation() {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data) => publishSpace(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ [
+ "public-space-for-space",
+ "published-spaces",
+ "space",
+ "spaces",
+ ].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ notifications.show({
+ message:
+ error?.["response"]?.data?.message || t("Failed to update space"),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/features/public-space/services/public-space-service.ts b/apps/client/src/features/public-space/services/public-space-service.ts
new file mode 100644
index 000000000..77899650d
--- /dev/null
+++ b/apps/client/src/features/public-space/services/public-space-service.ts
@@ -0,0 +1,73 @@
+import api from "@/lib/api-client";
+import { IPagination, QueryParams } from "@/lib/types.ts";
+import {
+ IPublicSpace,
+ IPublicSpaceDirectory,
+ IPublicSpaceInfo,
+ IPublicSpacePage,
+ IPublicSpaceTree,
+ IPublishedSpaceItem,
+ IPublishSpace,
+} from "@/features/public-space/types/public-space.types.ts";
+
+export async function getPublishedSpaces(
+ params?: QueryParams,
+): Promise> {
+ const req = await api.post>(
+ "/public-spaces",
+ params,
+ );
+ return req.data;
+}
+
+export async function getPublicSpaceInfo(
+ spaceSlug: string,
+): Promise {
+ const req = await api.post("/public-spaces/info", {
+ spaceSlug,
+ });
+ return req.data;
+}
+
+export async function getPublicSpaceTree(
+ spaceSlug: string,
+): Promise {
+ const req = await api.post("/public-spaces/tree", {
+ spaceSlug,
+ });
+ return req.data;
+}
+
+export async function getPublicSpacePage(params: {
+ spaceSlug: string;
+ pageSlugId?: string;
+ contentless?: boolean;
+}): Promise {
+ const req = await api.post(
+ "/public-spaces/page-info",
+ params,
+ );
+ return req.data;
+}
+
+export async function getPublicSpaceDirectory(): Promise {
+ const req = await api.post(
+ "/public-spaces/directory",
+ {},
+ );
+ return req.data;
+}
+
+export async function getPublicSpaceForSpace(
+ spaceId: string,
+): Promise {
+ const req = await api.post("/public-spaces/for-space", {
+ spaceId,
+ });
+ return req.data;
+}
+
+export async function publishSpace(data: IPublishSpace): Promise {
+ const req = await api.post("/public-spaces/publish", data);
+ return req.data;
+}
diff --git a/apps/client/src/features/public-space/theme/docs-theme.ts b/apps/client/src/features/public-space/theme/docs-theme.ts
new file mode 100644
index 000000000..4e9368335
--- /dev/null
+++ b/apps/client/src/features/public-space/theme/docs-theme.ts
@@ -0,0 +1,91 @@
+import { useEffect } from "react";
+import { useComputedColorScheme } from "@mantine/core";
+import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts";
+
+export type DocsThemePreset = {
+ id: string;
+ nameKey: string;
+ light: string;
+ dark: string;
+};
+
+export const DOCS_THEME_PRESETS: DocsThemePreset[] = [
+ { id: "default", nameKey: "Default", light: "#2b7af1", dark: "#6ea6f6" },
+ { id: "forest", nameKey: "Forest", light: "#0f766e", dark: "#2dd4bf" },
+ { id: "violet", nameKey: "Violet", light: "#6d28d9", dark: "#a78bfa" },
+ { id: "ember", nameKey: "Ember", light: "#c2410c", dark: "#fb923c" },
+ { id: "rose", nameKey: "Rose", light: "#be123c", dark: "#fb7185" },
+];
+
+export const DEFAULT_DOCS_PRESET = DOCS_THEME_PRESETS[0];
+
+const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
+
+export function isValidDocsColor(value: unknown): value is string {
+ return typeof value === "string" && HEX_COLOR_REGEX.test(value);
+}
+
+export function resolveDocsAccent(
+ appearance: IPublicSpaceAppearance | undefined,
+ scheme: "light" | "dark",
+): string {
+ const custom =
+ scheme === "dark"
+ ? appearance?.primaryColorDark
+ : appearance?.primaryColorLight;
+ if (isValidDocsColor(custom)) return custom;
+ return scheme === "dark"
+ ? DEFAULT_DOCS_PRESET.dark
+ : DEFAULT_DOCS_PRESET.light;
+}
+
+export function matchDocsPreset(
+ appearance: IPublicSpaceAppearance | undefined,
+): DocsThemePreset | null {
+ const light = appearance?.primaryColorLight;
+ const dark = appearance?.primaryColorDark;
+ if (!light && !dark) return DEFAULT_DOCS_PRESET;
+ return (
+ DOCS_THEME_PRESETS.find(
+ (preset) =>
+ preset.light.toLowerCase() === light?.toLowerCase() &&
+ preset.dark.toLowerCase() === dark?.toLowerCase(),
+ ) ?? null
+ );
+}
+
+// Set on documentElement (not the shell root) so portaled Mantine surfaces on
+// /docs routes (spotlight, drawers) follow the space accent too.
+const ACCENT_VARIABLES = (accent: string): Record => ({
+ "--docs-accent": accent,
+ "--docs-accent-soft": `color-mix(in srgb, ${accent} 10%, transparent)`,
+ "--mantine-primary-color-filled": accent,
+ "--mantine-primary-color-filled-hover": `color-mix(in srgb, ${accent} 85%, black)`,
+ "--mantine-primary-color-light": `color-mix(in srgb, ${accent} 10%, transparent)`,
+ "--mantine-primary-color-light-hover": `color-mix(in srgb, ${accent} 15%, transparent)`,
+ "--mantine-primary-color-light-color": accent,
+ "--mantine-color-anchor": accent,
+});
+
+export function useDocsAccent(appearance: IPublicSpaceAppearance | undefined) {
+ const scheme = useComputedColorScheme("light");
+ const light = appearance?.primaryColorLight;
+ const dark = appearance?.primaryColorDark;
+
+ useEffect(() => {
+ const accent = resolveDocsAccent(
+ { primaryColorLight: light, primaryColorDark: dark },
+ scheme,
+ );
+ const root = document.documentElement;
+ const variables = ACCENT_VARIABLES(accent);
+ for (const [name, value] of Object.entries(variables)) {
+ root.style.setProperty(name, value);
+ }
+ return () => {
+ for (const name of Object.keys(variables)) {
+ root.style.removeProperty(name);
+ }
+ };
+ }, [light, dark, scheme]);
+}
diff --git a/apps/client/src/features/public-space/types/public-space.types.ts b/apps/client/src/features/public-space/types/public-space.types.ts
new file mode 100644
index 000000000..55fabe9af
--- /dev/null
+++ b/apps/client/src/features/public-space/types/public-space.types.ts
@@ -0,0 +1,105 @@
+import { IPage } from "@/features/page/types/page.types.ts";
+
+export interface IPublicSpaceSummary {
+ id: string;
+ name: string;
+ slug: string;
+ description?: string;
+ logo?: string;
+}
+
+export interface IPublicSpaceAppearance {
+ primaryColorLight?: string;
+ primaryColorDark?: string;
+}
+
+export interface IPublicSpaceByline {
+ author: boolean;
+ updatedAt: boolean;
+}
+
+export interface IPublicSpaceInfo {
+ space: IPublicSpaceSummary;
+ searchIndexing: boolean;
+ appearance?: IPublicSpaceAppearance;
+ features?: string[];
+}
+
+export interface IPublicSpaceTree {
+ space: IPublicSpaceSummary;
+ pageTree: Partial;
+ appearance?: IPublicSpaceAppearance;
+ features?: string[];
+}
+
+export interface IPublicSpacePage {
+ page: IPage | null;
+ space: IPublicSpaceSummary;
+ searchIndexing: boolean;
+ appearance?: IPublicSpaceAppearance;
+ byline?: IPublicSpaceByline;
+ features?: string[];
+}
+
+export interface IPublicSpace {
+ id: string;
+ spaceId: string;
+ workspaceId: string;
+ enabled: boolean;
+ searchIndexing: boolean;
+ settings?: {
+ appearance?: IPublicSpaceAppearance;
+ byline?: Partial;
+ directory?: boolean;
+ } | null;
+ creatorId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface IPublishedSpaceItem {
+ id: string;
+ spaceId: string;
+ workspaceId: string;
+ searchIndexing: boolean;
+ settings?: IPublicSpace["settings"];
+ createdAt: string;
+ updatedAt: string;
+ space: {
+ id: string;
+ name: string;
+ slug: string;
+ logo?: string;
+ userRole: string;
+ };
+ creator: {
+ id: string;
+ name: string;
+ avatarUrl: string | null;
+ };
+}
+
+export interface IPublishSpace {
+ spaceId: string;
+ enabled: boolean;
+ searchIndexing?: boolean;
+ appearance?: {
+ primaryColorLight?: string | null;
+ primaryColorDark?: string | null;
+ };
+ bylineAuthor?: boolean;
+ bylineUpdatedAt?: boolean;
+ directory?: boolean;
+}
+
+export interface IPublicSpaceDirectoryEntry {
+ name: string;
+ slug: string;
+ description?: string;
+ logo?: string;
+}
+
+export interface IPublicSpaceDirectory {
+ spaces: IPublicSpaceDirectoryEntry[];
+ features?: string[];
+}
diff --git a/apps/client/src/features/public-space/utils/docs-tree.ts b/apps/client/src/features/public-space/utils/docs-tree.ts
new file mode 100644
index 000000000..22ad8ffb3
--- /dev/null
+++ b/apps/client/src/features/public-space/utils/docs-tree.ts
@@ -0,0 +1,39 @@
+import { SharedPageTreeNode } from "@/features/share/utils.ts";
+
+// Preorder walk of the whole tree, matching the sidebar's visual order. Drives
+// prev/next navigation independently of which nodes are expanded.
+export function flattenTreePreorder(
+ nodes: SharedPageTreeNode[],
+): SharedPageTreeNode[] {
+ const out: SharedPageTreeNode[] = [];
+ const walk = (list: SharedPageTreeNode[]) => {
+ for (const node of list) {
+ out.push(node);
+ if (node.children?.length) walk(node.children);
+ }
+ };
+ walk(nodes);
+ return out;
+}
+
+// Ancestors of the node with the given slugId, root-first, excluding the node
+// itself. Null when the slugId is not in the tree.
+export function findAncestorTrail(
+ nodes: SharedPageTreeNode[],
+ slugId: string,
+): SharedPageTreeNode[] | null {
+ const walk = (
+ list: SharedPageTreeNode[],
+ trail: SharedPageTreeNode[],
+ ): SharedPageTreeNode[] | null => {
+ for (const node of list) {
+ if (node.slugId === slugId) return trail;
+ if (node.children?.length) {
+ const found = walk(node.children, [...trail, node]);
+ if (found) return found;
+ }
+ }
+ return null;
+ };
+ return walk(nodes, []);
+}
diff --git a/apps/client/src/features/public-space/utils/public-space-access.ts b/apps/client/src/features/public-space/utils/public-space-access.ts
new file mode 100644
index 000000000..d10bc1753
--- /dev/null
+++ b/apps/client/src/features/public-space/utils/public-space-access.ts
@@ -0,0 +1,8 @@
+import { isBetaPublicSpaces } from "@/lib/config.ts";
+import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
+
+export function isPublicSpacesAllowed(workspace?: IWorkspace): boolean {
+ return (
+ isBetaPublicSpaces() && workspace?.settings?.publicSpaces?.enabled === true
+ );
+}
diff --git a/apps/client/src/features/search/components/public-space-search-spotlight.tsx b/apps/client/src/features/search/components/public-space-search-spotlight.tsx
new file mode 100644
index 000000000..0dca1e053
--- /dev/null
+++ b/apps/client/src/features/search/components/public-space-search-spotlight.tsx
@@ -0,0 +1,112 @@
+import { Group, Center, Text, Button } from "@mantine/core";
+import { Spotlight } from "@mantine/spotlight";
+import { IconLetterCase, IconSearch } from "@tabler/icons-react";
+import React, { useState } from "react";
+import { Link } from "react-router-dom";
+import { useDebouncedValue } from "@mantine/hooks";
+import { usePublicSpaceSearchQuery } from "@/features/search/queries/search-query";
+import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts";
+import { getPageIcon } from "@/lib";
+import { useTranslation } from "react-i18next";
+import { publicSpaceSearchSpotlightStore } from "@/features/search/constants.ts";
+import DOMPurify from "dompurify";
+
+interface PublicSpaceSearchSpotlightProps {
+ spaceSlug: string;
+}
+export function PublicSpaceSearchSpotlight({
+ spaceSlug,
+}: PublicSpaceSearchSpotlightProps) {
+ const { t } = useTranslation();
+ const [query, setQuery] = useState("");
+ const [titleOnly, setTitleOnly] = useState(false);
+ const [debouncedSearchQuery] = useDebouncedValue(query, 300);
+
+ const { data: searchResults } = usePublicSpaceSearchQuery({
+ query: debouncedSearchQuery,
+ spaceSlug,
+ ...(titleOnly && { titleOnly: true }),
+ });
+
+ const pages = (
+ searchResults && searchResults.length > 0 ? searchResults : []
+ ).map((page) => (
+
+
+ {getPageIcon(page?.icon)}
+
+
+ {page.title}
+
+ {page?.highlight && (
+
+ )}
+
+
+
+ ));
+
+ return (
+ <>
+
+ }
+ />
+
+ }
+ aria-pressed={titleOnly}
+ onClick={() => setTitleOnly((value) => !value)}
+ >
+ {t("Title only")}
+
+
+
+ {query.length === 0 && pages.length === 0 && (
+ {t("Start typing to search...")}
+ )}
+
+ {query.length > 0 && pages.length === 0 && (
+ {t("No results found...")}
+ )}
+
+ {pages.length > 0 && pages}
+
+
+ >
+ );
+}
diff --git a/apps/client/src/features/search/constants.ts b/apps/client/src/features/search/constants.ts
index a4c6c2f70..365387625 100644
--- a/apps/client/src/features/search/constants.ts
+++ b/apps/client/src/features/search/constants.ts
@@ -5,3 +5,6 @@ export const [searchSpotlightStore, searchSpotlight] = createSpotlight();
export const [shareSearchSpotlightStore, shareSearchSpotlight] =
createSpotlight();
+export const [publicSpaceSearchSpotlightStore, publicSpaceSearchSpotlight] =
+ createSpotlight();
+
diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts
index f536b441b..3cbc9a371 100644
--- a/apps/client/src/features/search/queries/search-query.ts
+++ b/apps/client/src/features/search/queries/search-query.ts
@@ -2,6 +2,7 @@ import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-quer
import {
searchAttachments,
searchPage,
+ searchPublicSpace,
searchShare,
searchSuggestions,
} from '@/features/search/services/search-service';
@@ -55,3 +56,13 @@ export function useAttachmentSearchQuery(
enabled: !!params.query,
});
}
+
+export function usePublicSpaceSearchQuery(
+ params: IPageSearchParams & { spaceSlug: string },
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["public-space-search", params],
+ queryFn: () => searchPublicSpace(params),
+ enabled: !!params.query && !!params.spaceSlug,
+ });
+}
diff --git a/apps/client/src/features/search/services/search-service.ts b/apps/client/src/features/search/services/search-service.ts
index 5e52ddf77..b94cccab9 100644
--- a/apps/client/src/features/search/services/search-service.ts
+++ b/apps/client/src/features/search/services/search-service.ts
@@ -28,6 +28,16 @@ export async function searchShare(
return req.data.items;
}
+export async function searchPublicSpace(
+ params: IPageSearchParams & { spaceSlug: string },
+): Promise {
+ const req = await api.post<{ items: IPageSearch[] }>(
+ "/search/public-space-search",
+ params,
+ );
+ return req.data.items;
+}
+
export async function searchAttachments(
params: IPageSearchParams,
): Promise {
diff --git a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts b/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts
deleted file mode 100644
index 47882e5e9..000000000
--- a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { atom } from "jotai";
-
-export const openSharedTreeNodesAtom = atom>({});
diff --git a/apps/client/src/features/share/atoms/shared-page-atom.ts b/apps/client/src/features/share/atoms/shared-page-atom.ts
index bf8929942..efb0940db 100644
--- a/apps/client/src/features/share/atoms/shared-page-atom.ts
+++ b/apps/client/src/features/share/atoms/shared-page-atom.ts
@@ -1,11 +1,10 @@
import { atom } from "jotai";
-import { atomWithStorage } from "jotai/utils";
import { ISharedPageTree } from "@/features/share/types/share.types";
import { SharedPageTreeNode } from "@/features/share/utils";
-export const sharedPageTreeAtom = atom(null);
-export const sharedTreeDataAtom = atom(null);
-export const sharedPageFullWidthAtom = atomWithStorage(
- "sharedPageFullWidth",
- false,
-);
\ No newline at end of file
+export const sharedPageTreeAtom = atom(
+ null as ISharedPageTree | null,
+);
+export const sharedTreeDataAtom = atom(
+ null as SharedPageTreeNode[] | null,
+);
diff --git a/apps/client/src/features/share/atoms/sidebar-atom.ts b/apps/client/src/features/share/atoms/sidebar-atom.ts
deleted file mode 100644
index 0bc9d6817..000000000
--- a/apps/client/src/features/share/atoms/sidebar-atom.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { atomWithWebStorage } from "@/lib/jotai-helper.ts";
-import { atom } from 'jotai';
-
-export const tableOfContentAsideAtom = atomWithWebStorage(
- "showTOC",
- true,
-);
-
-export const mobileTableOfContentAsideAtom = atom(false);
\ No newline at end of file
diff --git a/apps/client/src/features/share/components/share-branding.tsx b/apps/client/src/features/share/components/share-branding.tsx
deleted file mode 100644
index 4b3dfb3eb..000000000
--- a/apps/client/src/features/share/components/share-branding.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Affix, Button } from "@mantine/core";
-
-export default function ShareBranding() {
- return (
-
-