mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14840ef065 |
+1
-1
@@ -1,7 +1,7 @@
|
||||
FROM node:26-slim AS base
|
||||
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||
|
||||
RUN npm install -g pnpm@11.25.0
|
||||
RUN npm install -g pnpm@11.23.0
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"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",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.",
|
||||
"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": "OAuth-Erzwingung für MCP umschalten",
|
||||
"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.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Verwalten Sie API-Schlüssel für alle Nutzer im Arbeitsbereich. Siehe die <anchor>API-Dokumentation</anchor> für Details zur Verwendung.",
|
||||
@@ -1294,48 +1289,5 @@
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 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"
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -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 email addresses",
|
||||
"enter valid emails addresses": "enter valid emails 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 set up MFA",
|
||||
"Failed to setup MFA": "Failed to setup 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 sign up 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.",
|
||||
"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.",
|
||||
@@ -707,11 +707,9 @@
|
||||
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
|
||||
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
|
||||
"MCP Server URL": "MCP Server URL",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Connect AI assistants with your 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 assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
|
||||
"Toggle enforce OAuth for MCP": "Toggle enforce OAuth for MCP",
|
||||
"Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
|
||||
"Supported tools": "Supported tools",
|
||||
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
|
||||
"MCP server URL:": "MCP server URL:",
|
||||
"Learn more": "Learn more",
|
||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
|
||||
@@ -793,10 +791,6 @@
|
||||
"Removed page restriction": "Removed page restriction",
|
||||
"Added page permission": "Added page permission",
|
||||
"Removed page permission": "Removed page permission",
|
||||
"Changed page permission": "Changed page permission",
|
||||
"Requested password reset": "Requested password reset",
|
||||
"Created template": "Created template",
|
||||
"Deleted template": "Deleted template",
|
||||
"day": "day",
|
||||
"days": "days",
|
||||
"week": "week",
|
||||
@@ -833,7 +827,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",
|
||||
@@ -884,9 +878,6 @@
|
||||
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> returned a page for revision",
|
||||
"Page verification expires soon": "Page verification expires soon",
|
||||
"Page verification has expired": "Page verification has expired",
|
||||
"SIEM destination <bold>{{name}}</bold> is failing": "SIEM destination <bold>{{name}}</bold> is failing",
|
||||
"SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures": "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures",
|
||||
"SIEM destination <bold>{{name}}</bold> recovered": "SIEM destination <bold>{{name}}</bold> recovered",
|
||||
"Verifying your email": "Verifying your email",
|
||||
"Please wait...": "Please wait...",
|
||||
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
|
||||
@@ -912,7 +903,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 log in 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.",
|
||||
"AI-generated content may not be accurate.": "AI-generated content may not be accurate.",
|
||||
"AI Chat": "AI Chat",
|
||||
"Analyze for insights": "Analyze for insights",
|
||||
@@ -1258,7 +1249,7 @@
|
||||
"Not started": "Not started",
|
||||
"Number": "Number",
|
||||
"One month ago": "One month ago",
|
||||
"One month from now": "In one month",
|
||||
"One month from now": "One month from now",
|
||||
"One week ago": "One week ago",
|
||||
"One week from now": "One week from now",
|
||||
"Open as page": "Open as page",
|
||||
@@ -1313,30 +1304,6 @@
|
||||
"No attachments on this page yet.": "No attachments on this page yet.",
|
||||
"Uploaded by {{name}}": "Uploaded by {{name}}",
|
||||
"Download {{name}}": "Download {{name}}",
|
||||
"Access revoked": "Access revoked",
|
||||
"Authorize application": "Authorize application",
|
||||
"{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}",
|
||||
"Not you? Switch account": "Not you? Switch account",
|
||||
"This application will be able to:": "This application will be able to:",
|
||||
"Write": "Write",
|
||||
"Invalid authorization request": "Invalid authorization request",
|
||||
"Authorize": "Authorize",
|
||||
"Application": "Application",
|
||||
"Permissions": "Permissions",
|
||||
"Authorized": "Authorized",
|
||||
"Revoke access": "Revoke access",
|
||||
"Revoke access for {{name}}": "Revoke access for {{name}}",
|
||||
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
|
||||
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
|
||||
"Remove {{name}}": "Remove {{name}}",
|
||||
"Make sure you trust this application before authorizing it.": "Make sure you trust this application before authorizing it.",
|
||||
"You will be redirected to": "You will be redirected to",
|
||||
"View content without making changes.": "View content without making changes.",
|
||||
"Create and modify content.": "Create and modify content.",
|
||||
"Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
|
||||
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
|
||||
"Authorized apps": "Authorized apps",
|
||||
"No authorized apps yet.": "No authorized apps yet.",
|
||||
"Workspace knowledge only": "Workspace knowledge only",
|
||||
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
|
||||
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
|
||||
@@ -1344,60 +1311,5 @@
|
||||
"AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.",
|
||||
"Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode",
|
||||
"Title only": "Title only",
|
||||
"you": "you",
|
||||
"Actions": "Actions",
|
||||
"Add destination": "Add destination",
|
||||
"Are you sure you want to delete the destination": "Are you sure you want to delete the destination",
|
||||
"Audit logs": "Audit logs",
|
||||
"Audit logs & SIEM": "Audit logs & SIEM",
|
||||
"Auth header name": "Auth header name",
|
||||
"Auth header prefix": "Auth header prefix",
|
||||
"Body format": "Body format",
|
||||
"Created SIEM destination": "Created SIEM destination",
|
||||
"Datadog site": "Datadog site",
|
||||
"Defaults to this instance's hostname": "Defaults to this instance's hostname",
|
||||
"Delete destination": "Delete destination",
|
||||
"Deleted SIEM destination": "Deleted SIEM destination",
|
||||
"Destination created": "Destination created",
|
||||
"Destination deleted": "Destination deleted",
|
||||
"Destination updated": "Destination updated",
|
||||
"Disabled": "Disabled",
|
||||
"Edit destination": "Edit destination",
|
||||
"Endpoint URL": "Endpoint URL",
|
||||
"Failing": "Failing",
|
||||
"Failing since {{time}}": "Failing since {{time}}",
|
||||
"HEC token": "HEC token",
|
||||
"HEC URL": "HEC URL",
|
||||
"Healthy": "Healthy",
|
||||
"Hide advanced options": "Hide advanced options",
|
||||
"Host": "Host",
|
||||
"Index": "Index",
|
||||
"Insecure: connections can be intercepted.": "Insecure: connections can be intercepted.",
|
||||
"JSON array": "JSON array",
|
||||
"Last delivered": "Last delivered",
|
||||
"Last error": "Last error",
|
||||
"Leave empty to use the token's default index": "Leave empty to use the token's default index",
|
||||
"Maximum of {{limit}} destinations reached": "Maximum of {{limit}} destinations reached",
|
||||
"Could not load SIEM destinations: {{message}}": "Could not load SIEM destinations: {{message}}",
|
||||
"No destinations yet": "No destinations yet",
|
||||
"Preset": "Preset",
|
||||
"Retry now": "Retry now",
|
||||
"Retry scheduled": "Retry scheduled",
|
||||
"Send test event": "Send test event",
|
||||
"Sent in the auth header below. Leave empty if your receiver does not need one.": "Sent in the auth header below. Leave empty if your receiver does not need one.",
|
||||
"Service": "Service",
|
||||
"Show advanced options": "Show advanced options",
|
||||
"SIEM": "SIEM",
|
||||
"SIEM streaming": "SIEM streaming",
|
||||
"SIEM streaming requires an Enterprise license.": "SIEM streaming requires an Enterprise license.",
|
||||
"Source": "Source",
|
||||
"Sourcetype": "Sourcetype",
|
||||
"Tags": "Tags",
|
||||
"Test connection": "Test connection",
|
||||
"Test event delivered successfully.": "Test event delivered successfully.",
|
||||
"Test the connection before saving.": "Test the connection before saving.",
|
||||
"Test event delivered to {{name}}": "Test event delivered to {{name}}",
|
||||
"Updated SIEM destination": "Updated SIEM destination",
|
||||
"Verify TLS certificate": "Verify TLS certificate",
|
||||
"e.g. Splunk prod": "e.g. Splunk prod"
|
||||
"you": "you"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"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",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante 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": "Activar o desactivar la exigencia de OAuth para 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.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Gestiona las claves de API para todos los usuarios en el espacio de trabajo. Consulta la <anchor>documentación de la API</anchor> para detalles de uso.",
|
||||
@@ -1191,8 +1186,8 @@
|
||||
"Default value": "Valor predeterminado",
|
||||
"Delete property": "Eliminar propiedad",
|
||||
"Delete view": "Eliminar vista",
|
||||
"Delete {{count}} rows?_one": "¿Eliminar 1 fila?",
|
||||
"Delete {{count}} rows?_other": "¿Eliminar {{count}} filas?",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "Descendente",
|
||||
"Discard": "Descartar",
|
||||
"Doesn't contain": "No contiene",
|
||||
@@ -1291,51 +1286,8 @@
|
||||
"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 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ú"
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"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",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via 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": "Activer ou désactiver l’imposition d’OAuth pour 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.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Gérez les clés API pour tous les utilisateurs de l'espace de travail. Consultez la <anchor>documentation API</anchor> pour plus de détails sur l'utilisation.",
|
||||
@@ -1191,8 +1186,8 @@
|
||||
"Default value": "Valeur par défaut",
|
||||
"Delete property": "Supprimer la propriété",
|
||||
"Delete view": "Supprimer la vue",
|
||||
"Delete {{count}} rows?_one": "Supprimer 1 ligne ?",
|
||||
"Delete {{count}} rows?_other": "Supprimer {{count}} lignes ?",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "Décroissant",
|
||||
"Discard": "Ignorer",
|
||||
"Doesn't contain": "Ne contient pas",
|
||||
@@ -1291,51 +1286,8 @@
|
||||
"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 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"
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -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 intera della pagina",
|
||||
"Toggle full page width": "Attiva/disattiva larghezza completa della pagina",
|
||||
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
|
||||
"untitled": "senza titolo",
|
||||
"Untitled": "Senza titolo",
|
||||
@@ -294,7 +294,6 @@
|
||||
"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",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite 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": "Attiva/disattiva l'obbligo di OAuth per 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.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Gestisci le API key per tutti gli utenti nello spazio di lavoro. Consulta la <anchor>documentazione API</anchor> per i dettagli sull'utilizzo.",
|
||||
@@ -1191,8 +1186,8 @@
|
||||
"Default value": "Valore predefinito",
|
||||
"Delete property": "Elimina proprietà",
|
||||
"Delete view": "Elimina vista",
|
||||
"Delete {{count}} rows?_one": "Eliminare 1 riga?",
|
||||
"Delete {{count}} rows?_other": "Eliminare {{count}} righe?",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "Decrescente",
|
||||
"Discard": "Ignora",
|
||||
"Doesn't contain": "Non contiene",
|
||||
@@ -1291,51 +1286,8 @@
|
||||
"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 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"
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"Export space": "エクスポートスペース",
|
||||
"Export {{type}}": "{{type}}をエクスポート",
|
||||
"File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています",
|
||||
"Media": "メディア",
|
||||
"Align left": "左揃え",
|
||||
"Align right": "右揃え",
|
||||
"Align center": "中央揃え",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.": "デバイスから音声ファイルをアップロードします。",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。",
|
||||
"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": "MCP の OAuth 必須化を切り替え",
|
||||
"Use your API key for authentication. You can manage API keys in your account settings.": "認証には API キーを使用してください。API キーはアカウント設定で管理できます。",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "ワークスペース内のすべてのユーザーのAPIキーを管理します。利用方法の詳細は<anchor>APIドキュメント</anchor>をご覧ください。",
|
||||
@@ -1191,7 +1186,7 @@
|
||||
"Default value": "デフォルト値",
|
||||
"Delete property": "プロパティを削除",
|
||||
"Delete view": "ビューを削除",
|
||||
"Delete {{count}} rows?_one": "1行を削除しますか?",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "降順",
|
||||
"Discard": "破棄",
|
||||
@@ -1291,51 +1286,8 @@
|
||||
"Value": "値",
|
||||
"View updated for everyone": "ビューが全員向けに更新されました",
|
||||
"You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?",
|
||||
"{{count}} rows deleted_one": "1行を削除しました",
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1件を選択中",
|
||||
"{{count}} selected_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": "あなた"
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -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": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있음",
|
||||
"Can become members of groups and spaces in workspace": "Workspace 내 팀 및 Space의 사용자가 될 수 있습니다.",
|
||||
"Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.",
|
||||
"Can edit": "편집할 수 있음",
|
||||
"Can manage workspace": "워크스페이스를 관리할 수 있음",
|
||||
"Can manage workspace but cannot delete it": "워크스페이스를 관리할 수 있지만, 삭제할 수 없음",
|
||||
"Can manage workspace": "Workspace를 관리할 수 있음",
|
||||
"Can manage workspace but cannot delete it": "Workspace를 관리할 수 있지만, 삭제는 불가능.",
|
||||
"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": "워크스페이스 생성",
|
||||
"Create workspace": "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": "초대된 멤버에게는 그룹이 접근할 수 있는 스페이스에 대한 액세스 권한이 부여됩니다",
|
||||
"Join the workspace": "워크스페이스 참여",
|
||||
"Invited members will be granted access to spaces the groups can access": "초대된 사용자는 팀이 접근할 수 있는 Space에 대한 접근 권한을 받게 됩니다",
|
||||
"Join the workspace": "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.": "이 초대를 폐기하시겠습니까? 사용자가 워크스페이스에 참여할 수 없게 됩니다.",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
|
||||
"Resend invitation": "초대 재전송",
|
||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 워크스페이스에 참여할 수 있습니다.",
|
||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 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,11 +290,10 @@
|
||||
"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": "가운데 정렬",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.": "기기에서 오디오를 업로드하세요.",
|
||||
@@ -450,12 +447,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: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "작성 시간: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "{{name}}님이 {{time}}에 편집함",
|
||||
@@ -468,7 +465,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": "멤버 활성화",
|
||||
@@ -501,32 +498,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)",
|
||||
@@ -542,13 +539,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.": "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단계 인증이 비활성화됨",
|
||||
"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단계 인증이 비활성화되었습니다",
|
||||
"2-step verification": "2단계 인증",
|
||||
"Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.",
|
||||
"Two-factor authentication is active on your account.": "2단계 인증이 계정에 활성화되어 있습니다.",
|
||||
"Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.",
|
||||
"Add 2FA method": "2FA 방법 추가",
|
||||
"Backup codes": "백업 코드",
|
||||
"Disable": "비활성화",
|
||||
@@ -578,13 +575,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.": "2단계 인증이 설정되었습니다. 다시 로그인해 주세요.",
|
||||
"Two-Factor authentication required": "2단계 인증 필요",
|
||||
"Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.",
|
||||
"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.": "워크스페이스에 계속 액세스하려면 2단계 인증을 설정해야 합니다. 이는 계정의 보안을 한층 강화합니다.",
|
||||
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.",
|
||||
"Set up two-factor authentication": "2단계 인증 설정",
|
||||
"Cancel and logout": "취소하고 로그아웃",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 2단계 인증이 필요합니다. 계속하려면 설정해 주세요.",
|
||||
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.",
|
||||
"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자 이상이어야 합니다",
|
||||
@@ -593,10 +590,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.": "계정 생성됨. 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.": "비밀번호 재설정됨. 새 비밀번호로 로그인하세요.",
|
||||
"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.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.",
|
||||
"Two-factor authentication": "2단계 인증",
|
||||
"Use authenticator app instead": "대신 인증 앱 사용",
|
||||
"Verify backup code": "백업 코드 확인",
|
||||
@@ -618,7 +615,7 @@
|
||||
"Permanently delete": "영구 삭제",
|
||||
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b>님이 {{time}}에 이 페이지를 휴지통으로 이동했습니다.",
|
||||
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
|
||||
"Page restored successfully": "페이지 복원됨",
|
||||
"Page restored successfully": "페이지가 성공적으로 복원되었습니다",
|
||||
"Deleted by": "삭제한 사람",
|
||||
"Deleted at": "삭제 시간",
|
||||
"Preview": "미리보기",
|
||||
@@ -641,7 +638,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.": "시행되면 모든 멤버가 워크스페이스에 액세스하기 위해 2단계 인증을 활성화해야 합니다.",
|
||||
"Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 작업 공간에 액세스하기 위해 이중 인증을 활성화해야 합니다.",
|
||||
"Toggle MFA enforcement": "MFA 강제 설정 전환",
|
||||
"Display name": "표시 이름",
|
||||
"Allow signup": "가입 허용",
|
||||
@@ -660,7 +657,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 관리",
|
||||
@@ -672,7 +669,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": "업데이트",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.",
|
||||
"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": "MCP에 대해 OAuth 강제 전환",
|
||||
"Use your API key for authentication. You can manage API keys in your account settings.": "인증을 위해 API 키를 사용하세요. API 키는 계정 설정에서 관리할 수 있습니다.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "워크스페이스의 모든 사용자를 위한 API 키를 관리하세요. 사용 방법은 <anchor>API 문서</anchor>를 참고하세요.",
|
||||
@@ -944,8 +939,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}} 생성됨",
|
||||
@@ -958,7 +953,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이 우선 적용됩니다.",
|
||||
@@ -1191,8 +1186,8 @@
|
||||
"Default value": "기본값",
|
||||
"Delete property": "속성 삭제",
|
||||
"Delete view": "보기 삭제",
|
||||
"Delete {{count}} rows?_one": "1개 행을 삭제하시겠습니까?",
|
||||
"Delete {{count}} rows?_other": "{{count}}개 행을 삭제하시겠습니까?",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "내림차순",
|
||||
"Discard": "삭제",
|
||||
"Doesn't contain": "포함하지 않음",
|
||||
@@ -1291,51 +1286,8 @@
|
||||
"Value": "값",
|
||||
"View updated for everyone": "보기가 모두에게 업데이트되었습니다",
|
||||
"You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?",
|
||||
"{{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": "회원님"
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"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",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via 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": "OAuth afdwingen voor MCP in- of uitschakelen",
|
||||
"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.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Beheer API-sleutels voor alle gebruikers in de werkruimte. Bekijk de <anchor>API-documentatie</anchor> voor gebruiksdetails.",
|
||||
@@ -1294,48 +1289,5 @@
|
||||
"{{count}} rows deleted_one": "1 rij verwijderd",
|
||||
"{{count}} rows deleted_other": "{{count}} rijen verwijderd",
|
||||
"{{count}} selected_one": "1 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"
|
||||
"{{count}} selected_other": "{{count}} geselecteerd"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"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",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via 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": "Ativar/desativar exigência de OAuth para 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.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Gerencie as chaves de API de todos os usuários do workspace. Veja a <anchor>documentação da API</anchor> para detalhes de uso.",
|
||||
@@ -1294,48 +1289,5 @@
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 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ê"
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"Export space": "Экспорт пространства",
|
||||
"Export {{type}}": "Экспорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}",
|
||||
"Media": "Медиа",
|
||||
"Align left": "По левому краю",
|
||||
"Align right": "По правому краю",
|
||||
"Align center": "По центру",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.": "Загрузите любой аудиофайл с вашего устройства.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через 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": "Переключить обязательное использование OAuth для MCP",
|
||||
"Use your API key for authentication. You can manage API keys in your account settings.": "Используйте ваш API-ключ для аутентификации. Управлять API-ключами можно в настройках аккаунта.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||
@@ -1191,8 +1186,8 @@
|
||||
"Default value": "Значение по умолчанию",
|
||||
"Delete property": "Удалить свойство",
|
||||
"Delete view": "Удалить представление",
|
||||
"Delete {{count}} rows?_one": "Удалить 1 строку?",
|
||||
"Delete {{count}} rows?_other": "Удалить {{count}} строк?",
|
||||
"Delete {{count}} rows?_one": "Delete 1 row?",
|
||||
"Delete {{count}} rows?_other": "Delete {{count}} rows?",
|
||||
"Descending": "По убыванию",
|
||||
"Discard": "Отменить",
|
||||
"Doesn't contain": "Не содержит",
|
||||
@@ -1291,51 +1286,8 @@
|
||||
"Value": "Значение",
|
||||
"View updated for everyone": "Представление обновлено для всех",
|
||||
"You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?",
|
||||
"{{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": "вы"
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 selected",
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"Export space": "Експорт простору",
|
||||
"Export {{type}}": "Експорт {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
|
||||
"Media": "Медіа",
|
||||
"Align left": "По лівому краю",
|
||||
"Align right": "По правому краю",
|
||||
"Align center": "По центру",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.": "Завантажте будь-який аудіофайл зі свого пристрою.",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через 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": "Увімкнути обов’язковий OAuth для MCP",
|
||||
"Use your API key for authentication. You can manage API keys in your account settings.": "Використовуйте свій API‑ключ для аутентифікації. Ви можете керувати API‑ключами в налаштуваннях облікового запису.",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "Керуйте ключами API для всіх користувачів у робочому просторі. Перегляньте <anchor>документацію API</anchor> для деталей використання.",
|
||||
@@ -1294,48 +1289,5 @@
|
||||
"{{count}} rows deleted_one": "1 row deleted",
|
||||
"{{count}} rows deleted_other": "{{count}} rows deleted",
|
||||
"{{count}} selected_one": "1 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": "ви"
|
||||
"{{count}} selected_other": "{{count}} selected"
|
||||
}
|
||||
|
||||
@@ -294,7 +294,6 @@
|
||||
"Export space": "导出空间",
|
||||
"Export {{type}}": "导出为 {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制",
|
||||
"Media": "媒体",
|
||||
"Align left": "靠左对齐",
|
||||
"Align right": "靠右对齐",
|
||||
"Align center": "居中对齐",
|
||||
@@ -388,8 +387,6 @@
|
||||
"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.": "从您的设备上传任意音频文件。",
|
||||
@@ -707,11 +704,9 @@
|
||||
"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",
|
||||
"Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。",
|
||||
"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": "切换 MCP 的强制使用 OAuth 设置",
|
||||
"Use your API key for authentication. You can manage API keys in your account settings.": "使用您的 API 密钥进行身份验证。您可以在账户设置中管理 API 密钥。",
|
||||
"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 <anchor>API documentation</anchor> for usage details.": "为工作区内所有用户管理 API 密钥。有关使用详情,请查阅<anchor>API 文档</anchor>。",
|
||||
@@ -1294,48 +1289,5 @@
|
||||
"{{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": "你"
|
||||
"{{count}} selected_other": "已选择 {{count}} 项"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
|
||||
|
||||
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
|
||||
const LoginPage = lazy(() => import("@/pages/auth/login"));
|
||||
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||
@@ -74,7 +73,6 @@ const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||
const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
|
||||
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
|
||||
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -97,7 +95,6 @@ export default function App() {
|
||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
|
||||
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
|
||||
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
|
||||
|
||||
{!isCloud() && (
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
@@ -151,10 +148,6 @@ export default function App() {
|
||||
element={<AccountPreferences />}
|
||||
/>
|
||||
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
||||
<Route
|
||||
path={"account/api-keys/authorized-apps"}
|
||||
element={<UserApiKeys />}
|
||||
/>
|
||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||
@@ -166,11 +159,6 @@ export default function App() {
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"ai/mcp"} element={<AiSettings />} />
|
||||
<Route path={"audit"} element={<AuditLogs />} />
|
||||
<Route path={"audit/siem"} element={<AuditLogs />} />
|
||||
<Route
|
||||
path={"siem"}
|
||||
element={<Navigate to="/settings/audit/siem" replace />}
|
||||
/>
|
||||
<Route path={"verifications"} element={<VerifiedPages />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
|
||||
@@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
label: "Audit logs & SIEM",
|
||||
label: "Audit log",
|
||||
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 logs & SIEM":
|
||||
case "Audit log":
|
||||
prefetchHandler = prefetchAuditLogs;
|
||||
break;
|
||||
case "Verified pages":
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Group,
|
||||
List,
|
||||
Text,
|
||||
@@ -108,11 +107,11 @@ export default function McpSettings() {
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t("Connect AI assistants with your Docmost account via OAuth.")}
|
||||
{t(
|
||||
"Use your API key for authentication. You can manage API keys in your account settings.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<McpEnforceOauthSetting />
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={500} mt="md" mb={4}>
|
||||
{t("Supported tools")}
|
||||
@@ -155,56 +154,3 @@ export default function McpSettings() {
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function McpEnforceOauthSetting() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.settings?.ai?.enforceMcpOauth);
|
||||
const hasAccess = useHasFeature(Feature.MCP_CONTROLS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ enforceMcpOauth: value });
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("Enforce OAuth")}
|
||||
</Text>
|
||||
{!hasAccess && (
|
||||
<Badge variant="light" size="sm" radius="sm">
|
||||
{t("Enterprise")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
|
||||
<Switch
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={!hasAccess}
|
||||
aria-label={t("Toggle enforce OAuth for MCP")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { Anchor, Alert, Button, Group, Space, Tabs, Text } from "@mantine/core";
|
||||
import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
@@ -17,8 +17,6 @@ import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { DocumentTitle } from "@/components/ui/document-title.tsx";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { AuthorizedAppsPanel } from "@/ee/oauth/components/authorized-apps-panel.tsx";
|
||||
|
||||
export default function UserApiKeys() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,11 +29,6 @@ export default function UserApiKeys() {
|
||||
const { data, isLoading } = useGetApiKeysQuery({ cursor });
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const { isAdmin } = useUserRole();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeTab = location.pathname.endsWith("/authorized-apps")
|
||||
? "authorized-apps"
|
||||
: "api-keys";
|
||||
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
|
||||
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
|
||||
const canCreate = !restrictToAdmins || isAdmin;
|
||||
@@ -54,27 +47,26 @@ export default function UserApiKeys() {
|
||||
setRevokeModalOpened(true);
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
navigate(
|
||||
value === "authorized-apps"
|
||||
? "/settings/account/api-keys/authorized-apps"
|
||||
: "/settings/account/api-keys",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle
|
||||
title={activeTab === "authorized-apps" ? t("Authorized apps") : t("API keys")}
|
||||
/>
|
||||
<DocumentTitle title={t("API keys")} />
|
||||
|
||||
<SettingsTitle title={t("API keys")} />
|
||||
|
||||
{mcpEnabled && (
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
<Trans
|
||||
i18nKey="View the <anchor>API documentation</anchor> for usage details."
|
||||
components={{
|
||||
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{mcpEnabled && canCreate && (
|
||||
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
|
||||
"Your workspace has MCP enabled. Use your API key to connect AI assistants.",
|
||||
)}{" "}
|
||||
<Anchor
|
||||
href="https://docmost.com/docs/user-guide/mcp"
|
||||
@@ -93,70 +85,37 @@ export default function UserApiKeys() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab fw={500} value="api-keys">
|
||||
{t("API keys")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab fw={500} value="authorized-apps">
|
||||
{t("Authorized apps")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="api-keys" pt="md">
|
||||
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
<Trans
|
||||
i18nKey="View the <anchor>API documentation</anchor> for usage details."
|
||||
components={{
|
||||
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => setCreateModalOpened(true)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("Create API Key")}
|
||||
</Button>
|
||||
)}
|
||||
{canCreate ? (
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Button onClick={() => setCreateModalOpened(true)}>
|
||||
{t("Create API Key")}
|
||||
</Button>
|
||||
</Group>
|
||||
) : restrictToAdmins ? (
|
||||
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
|
||||
<Text size="sm">
|
||||
{t("API key creation is restricted to admins by your workspace administrator.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!canCreate && restrictToAdmins && (
|
||||
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
|
||||
<Text size="sm">
|
||||
{t("API key creation is restricted to admins by your workspace administrator.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<ApiKeyTable
|
||||
apiKeys={data?.items || []}
|
||||
isLoading={isLoading}
|
||||
onUpdate={handleUpdate}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
|
||||
<ApiKeyTable
|
||||
apiKeys={data?.items || []}
|
||||
isLoading={isLoading}
|
||||
onUpdate={handleUpdate}
|
||||
onRevoke={handleRevoke}
|
||||
<Space h="md" />
|
||||
|
||||
{data?.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
{data?.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="authorized-apps" pt="md">
|
||||
<AuthorizedAppsPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<CreateApiKeyModal
|
||||
opened={createModalOpened}
|
||||
|
||||
@@ -22,7 +22,6 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"user.role_changed": "Changed user role",
|
||||
"user.password_changed": "Changed password",
|
||||
"user.password_reset": "Reset password",
|
||||
"user.password_reset_requested": "Requested password reset",
|
||||
"user.updated": "Updated user",
|
||||
"user.deactivated": "Deactivated user",
|
||||
"user.activated": "Activated user",
|
||||
@@ -63,7 +62,6 @@ export const auditEventLabels: Record<string, string> = {
|
||||
"page.restriction_removed": "Removed page restriction",
|
||||
"page.permission_added": "Added page permission",
|
||||
"page.permission_removed": "Removed page permission",
|
||||
"page.permission_role_changed": "Changed page permission",
|
||||
"page.verification_created": "Created page verification",
|
||||
"page.verification_updated": "Updated page verification",
|
||||
"page.verification_removed": "Removed page verification",
|
||||
@@ -81,13 +79,6 @@ export const auditEventLabels: Record<string, string> = {
|
||||
|
||||
"license.activated": "Activated license",
|
||||
"license.removed": "Removed license",
|
||||
|
||||
"siem_destination.created": "Created SIEM destination",
|
||||
"siem_destination.updated": "Updated SIEM destination",
|
||||
"siem_destination.deleted": "Deleted SIEM destination",
|
||||
|
||||
"template.created": "Created template",
|
||||
"template.deleted": "Deleted template",
|
||||
};
|
||||
|
||||
export function getEventLabel(event: string): string {
|
||||
@@ -114,10 +105,6 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "user.activated", label: "Activated user" },
|
||||
{ value: "user.role_changed", label: "Changed user role" },
|
||||
{ value: "user.password_changed", label: "Changed password" },
|
||||
{
|
||||
value: "user.password_reset_requested",
|
||||
label: "Requested password reset",
|
||||
},
|
||||
{ value: "user.mfa_enabled", label: "Enabled MFA" },
|
||||
{ value: "user.mfa_disabled", label: "Disabled MFA" },
|
||||
],
|
||||
@@ -160,10 +147,6 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "page.restriction_removed", label: "Removed page restriction" },
|
||||
{ value: "page.permission_added", label: "Added page permission" },
|
||||
{ value: "page.permission_removed", label: "Removed page permission" },
|
||||
{
|
||||
value: "page.permission_role_changed",
|
||||
label: "Changed page permission",
|
||||
},
|
||||
{ value: "page.verification_created", label: "Created page verification" },
|
||||
{ value: "page.verification_updated", label: "Updated page verification" },
|
||||
{ value: "page.verification_removed", label: "Removed page verification" },
|
||||
@@ -210,19 +193,4 @@ export const eventFilterOptions: EventGroup[] = [
|
||||
{ value: "license.removed", label: "Removed license" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "SIEM",
|
||||
items: [
|
||||
{ value: "siem_destination.created", label: "Created SIEM destination" },
|
||||
{ value: "siem_destination.updated", label: "Updated SIEM destination" },
|
||||
{ value: "siem_destination.deleted", label: "Deleted SIEM destination" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Template",
|
||||
items: [
|
||||
{ value: "template.created", label: "Created template" },
|
||||
{ value: "template.deleted", label: "Deleted template" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import Paginate from "@/components/common/paginate";
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
import { IAuditLogParams } from "@/ee/audit/types/audit.types";
|
||||
import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels";
|
||||
import AuditLogsTable from "@/ee/audit/components/audit-logs-table";
|
||||
import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
import { DocumentTitle } from "@/components/ui/document-title.tsx";
|
||||
|
||||
@@ -51,8 +48,6 @@ export default function AuditLogs() {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner } = useUserRole();
|
||||
const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [eventFilter, setEventFilter] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -90,8 +85,6 @@ export default function AuditLogs() {
|
||||
|
||||
const { data, isLoading } = useAuditLogsQuery(params);
|
||||
|
||||
const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit";
|
||||
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
@@ -101,150 +94,125 @@ export default function AuditLogs() {
|
||||
resetCursor();
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (value === "siem") {
|
||||
navigate("/settings/audit/siem");
|
||||
} else {
|
||||
navigate("/settings/audit");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle title={t("Audit logs & SIEM")} />
|
||||
<DocumentTitle title={t("Audit log")} />
|
||||
|
||||
<SettingsTitle title={t("Audit logs & SIEM")} />
|
||||
<SettingsTitle title={t("Audit log")} />
|
||||
|
||||
<Tabs color="dark" value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab fw={500} value="audit">
|
||||
{t("Audit logs")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab fw={500} value="siem">
|
||||
{t("SIEM")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Group mb="md" gap="sm">
|
||||
<Select
|
||||
placeholder={t("Filter by event")}
|
||||
data={eventFilterOptions.map((group) => ({
|
||||
group: t(group.group),
|
||||
items: group.items.map((item) => ({
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
})),
|
||||
}))}
|
||||
value={eventFilter}
|
||||
onChange={handleEventChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Tabs.Panel value="audit" pt="md">
|
||||
<Group mb="md" gap="sm">
|
||||
<Select
|
||||
placeholder={t("Filter by event")}
|
||||
data={eventFilterOptions.map((group) => ({
|
||||
group: t(group.group),
|
||||
items: group.items.map((item) => ({
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
})),
|
||||
}))}
|
||||
value={eventFilter}
|
||||
onChange={handleEventChange}
|
||||
clearable
|
||||
searchable
|
||||
w={220}
|
||||
size="sm"
|
||||
/>
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withArrow
|
||||
opened={settingsOpen}
|
||||
onChange={(opened) => {
|
||||
if (!opened) resetRetentionForm();
|
||||
setSettingsOpen(opened);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Audit settings")}>
|
||||
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
||||
<IconSettings size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text fz="sm" fw={500} mb={4}>
|
||||
{t("Retention")}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{t("Logs older than this period are automatically deleted.")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" mb="sm">
|
||||
<NumberInput
|
||||
value={retentionAmount}
|
||||
onChange={(val) => setRetentionAmount(val)}
|
||||
min={1}
|
||||
hideControls
|
||||
size="sm"
|
||||
w={60}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{ value: "days", label: t("days") },
|
||||
{ value: "months", label: t("months") },
|
||||
{ value: "years", label: t("years") },
|
||||
]}
|
||||
value={retentionUnit}
|
||||
onChange={(value) => {
|
||||
if (value === "days" || value === "months" || value === "years") {
|
||||
setRetentionUnit(value);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
resetRetentionForm();
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||
const clamped = Math.max(1, num);
|
||||
setRetentionAmount(clamped);
|
||||
const days = retentionToDays(clamped, retentionUnit);
|
||||
if (days !== currentDays) {
|
||||
updateRetention.mutate({ auditRetentionDays: days });
|
||||
}
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
loading={updateRetention.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
|
||||
<Popover
|
||||
position="bottom-end"
|
||||
shadow="md"
|
||||
width={260}
|
||||
withArrow
|
||||
opened={settingsOpen}
|
||||
onChange={(opened) => {
|
||||
if (!opened) resetRetentionForm();
|
||||
setSettingsOpen(opened);
|
||||
}}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Audit settings")}>
|
||||
<ActionIcon variant="default" size="input-sm" ml="auto" onClick={() => setSettingsOpen((o) => !o)}>
|
||||
<IconSettings size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Text fz="sm" fw={500} mb={4}>
|
||||
{t("Retention")}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{t("Logs older than this period are automatically deleted.")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" mb="sm">
|
||||
<NumberInput
|
||||
value={retentionAmount}
|
||||
onChange={(val) => setRetentionAmount(val)}
|
||||
min={1}
|
||||
hideControls
|
||||
size="sm"
|
||||
w={60}
|
||||
/>
|
||||
<Select
|
||||
data={[
|
||||
{ value: "days", label: t("days") },
|
||||
{ value: "months", label: t("months") },
|
||||
{ value: "years", label: t("years") },
|
||||
]}
|
||||
value={retentionUnit}
|
||||
onChange={(value) => {
|
||||
if (value === "days" || value === "months" || value === "years") {
|
||||
setRetentionUnit(value);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" grow>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
resetRetentionForm();
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const num = typeof retentionAmount === "number" ? retentionAmount : 1;
|
||||
const clamped = Math.max(1, num);
|
||||
setRetentionAmount(clamped);
|
||||
const days = retentionToDays(clamped, retentionUnit);
|
||||
if (days !== currentDays) {
|
||||
updateRetention.mutate({ auditRetentionDays: days });
|
||||
}
|
||||
setSettingsOpen(false);
|
||||
}}
|
||||
loading={updateRetention.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
||||
|
||||
<AuditLogsTable items={data?.items} isLoading={isLoading} />
|
||||
<Space h="md" />
|
||||
|
||||
<Space h="md" />
|
||||
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="siem" pt="md">
|
||||
<SiemStreamingPanel />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<Paginate
|
||||
hasPrevPage={data?.meta?.hasPrevPage}
|
||||
hasNextPage={data?.meta?.hasNextPage}
|
||||
onNext={() => goNext(data?.meta?.nextCursor)}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,5 @@ export const Feature = {
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
OAUTH: 'oauth',
|
||||
AI_CONTROLS: 'ai:controls',
|
||||
MCP_CONTROLS: 'mcp:controls',
|
||||
SIEM: 'siem',
|
||||
} as const;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OAuthGrantsTable } from "@/ee/oauth/components/oauth-grants-table";
|
||||
import { useOAuthGrantsQuery } from "@/ee/oauth/queries/oauth-query";
|
||||
|
||||
export function AuthorizedAppsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useOAuthGrantsQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t("Applications and AI assistants you have authorized to access your account.")}
|
||||
</Text>
|
||||
|
||||
<OAuthGrantsTable grants={data || []} isLoading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { ActionIcon, Badge, Group, Skeleton, Table, Text, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import NoTableResults from "@/components/common/no-table-results";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
|
||||
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
|
||||
import { useRevokeOAuthGrantMutation } from "@/ee/oauth/queries/oauth-query";
|
||||
|
||||
// Callback hosts identify the app; the full URL belongs on the consent screen.
|
||||
function callbackHosts(redirectUris: string[] = []): string[] {
|
||||
const hosts = redirectUris.map((uri) => {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
return url.protocol === "http:" || url.protocol === "https:"
|
||||
? url.host
|
||||
: url.protocol;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
});
|
||||
return Array.from(new Set(hosts));
|
||||
}
|
||||
|
||||
type OAuthGrantsTableProps = {
|
||||
grants: IOAuthGrant[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={140} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
<Skeleton height={20} width={50} />
|
||||
<Skeleton height={20} width={55} />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={90} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={90} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={28} width={28} circle />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function OAuthGrantsTable({ grants, isLoading }: OAuthGrantsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
const revokeMutation = useRevokeOAuthGrantMutation();
|
||||
|
||||
const scopeMeta: Record<string, { color: string; label: string }> = {
|
||||
read: { color: "blue", label: t("Read") },
|
||||
write: { color: "orange", label: t("Write") },
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return t("Never");
|
||||
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
|
||||
};
|
||||
|
||||
const openRevokeModal = (grant: IOAuthGrant) =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Revoke access"),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
|
||||
{ name: grant.clientName },
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Revoke access"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => revokeMutation.mutate(grant.id),
|
||||
});
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Application")}</Table.Th>
|
||||
<Table.Th>{t("Permissions")}</Table.Th>
|
||||
<Table.Th>{t("Authorized")}</Table.Th>
|
||||
<Table.Th>{t("Last used")}</Table.Th>
|
||||
<Table.Th aria-label={t("Action")} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : grants.length > 0 ? (
|
||||
grants.map((grant) => (
|
||||
<Table.Tr key={grant.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{grant.clientName}
|
||||
</Text>
|
||||
<Text
|
||||
fz="xs"
|
||||
c="dimmed"
|
||||
title={grant.redirectUris?.join("\n")}
|
||||
style={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
{callbackHosts(grant.redirectUris).join(", ")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{grant.scopes.map((scope) => {
|
||||
const meta = scopeMeta[scope];
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="light"
|
||||
color={meta.color}
|
||||
size="sm"
|
||||
>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(grant.createdAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{formatDate(grant.lastUsedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
<Tooltip label={t("Revoke access")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Revoke access for {{name}}", {
|
||||
name: grant.clientName,
|
||||
})}
|
||||
onClick={() => openRevokeModal(grant)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={5} text={t("No authorized apps yet.")} />
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Checkbox,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconEye,
|
||||
IconPencil,
|
||||
} from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { DocumentTitle } from "@/components/ui/document-title.tsx";
|
||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||
import useCurrentUser from "@/features/user/hooks/use-current-user";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { logout } from "@/features/auth/services/auth-service";
|
||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import {
|
||||
approveOAuthAuthorization,
|
||||
getOAuthAuthorizeInfo,
|
||||
} from "@/ee/oauth/services/oauth-service";
|
||||
import {
|
||||
IApproveAuthorizationPayload,
|
||||
IAuthorizeParams,
|
||||
IOAuthAuthorizeInfo,
|
||||
} from "@/ee/oauth/types/oauth.types";
|
||||
|
||||
function loginRedirectUrl(pathname: string, search: string): string {
|
||||
return `${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(pathname + search)}`;
|
||||
}
|
||||
|
||||
function errorStatus(error: any): number | undefined {
|
||||
return error?.response?.status;
|
||||
}
|
||||
|
||||
function errorText(error: any): string | undefined {
|
||||
const data = error?.response?.data;
|
||||
if (typeof data?.error_description === "string") return data.error_description;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
return data?.message;
|
||||
}
|
||||
|
||||
const OAUTH_PARAM_NAMES = [
|
||||
"response_type",
|
||||
"client_id",
|
||||
"redirect_uri",
|
||||
"state",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"scope",
|
||||
"resource",
|
||||
];
|
||||
|
||||
function pickOAuthParams(params: IAuthorizeParams): IAuthorizeParams {
|
||||
const picked: IAuthorizeParams = {};
|
||||
for (const name of OAUTH_PARAM_NAMES) {
|
||||
if (params[name] !== undefined) {
|
||||
picked[name] = params[name];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export default function OAuthConsent() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const params = useMemo<IAuthorizeParams>(
|
||||
() => Object.fromEntries(searchParams.entries()),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const currentUserQuery = useCurrentUser();
|
||||
const infoQuery = useQuery({
|
||||
queryKey: ["oauth-authorize-info", params],
|
||||
queryFn: () => getOAuthAuthorizeInfo(params),
|
||||
});
|
||||
|
||||
const isUnauthenticated =
|
||||
errorStatus(currentUserQuery.error) === 401 ||
|
||||
errorStatus(infoQuery.error) === 401;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUnauthenticated) {
|
||||
window.location.replace(
|
||||
loginRedirectUrl(location.pathname, location.search),
|
||||
);
|
||||
}
|
||||
}, [isUnauthenticated, location.pathname, location.search]);
|
||||
|
||||
const isLoading =
|
||||
isUnauthenticated || currentUserQuery.isLoading || infoQuery.isLoading;
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<DocumentTitle title={t("Authorize application")} />
|
||||
<Container size={460} className={classes.container}>
|
||||
<Box p="xl">
|
||||
{isLoading ? (
|
||||
<Center mih={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : infoQuery.data && currentUserQuery.data ? (
|
||||
<ConsentCard
|
||||
info={infoQuery.data}
|
||||
currentUser={currentUserQuery.data}
|
||||
params={params}
|
||||
/>
|
||||
) : (
|
||||
<InvalidRequestCard
|
||||
description={errorText(infoQuery.error ?? currentUserQuery.error)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function InvalidRequestCard({ description }: { description?: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack align="center" gap="sm">
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="red">
|
||||
<IconAlertTriangle size={26} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} ta="center" fw={600}>
|
||||
{t("Invalid authorization request")}
|
||||
</Title>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
type ConsentCardProps = {
|
||||
info: IOAuthAuthorizeInfo;
|
||||
currentUser: ICurrentUser;
|
||||
params: IAuthorizeParams;
|
||||
};
|
||||
|
||||
function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [, setCurrentUser] = useAtom(currentUserAtom);
|
||||
const [approvedScopes, setApprovedScopes] = useState<string[]>(
|
||||
info.scopes.filter((scope) => scope === "read" || scope === "write"),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null);
|
||||
|
||||
const scopeRows = [
|
||||
{
|
||||
scope: "read",
|
||||
icon: <IconEye size={16} stroke={1.5} />,
|
||||
label: t("Read"),
|
||||
description: t("View data in your workspace without making changes."),
|
||||
},
|
||||
{
|
||||
scope: "write",
|
||||
icon: <IconPencil size={16} stroke={1.5} />,
|
||||
label: t("Write"),
|
||||
description: t("Create and modify data in your workspace."),
|
||||
},
|
||||
];
|
||||
|
||||
function toggleScope(scope: string, checked: boolean) {
|
||||
setApprovedScopes((prev) =>
|
||||
checked ? [...prev, scope] : prev.filter((item) => item !== scope),
|
||||
);
|
||||
}
|
||||
|
||||
async function submitDecision(approved: boolean) {
|
||||
setSubmitting(approved ? "approve" : "deny");
|
||||
const oauthParams = pickOAuthParams(params);
|
||||
const payload: IApproveAuthorizationPayload = approved
|
||||
? { ...oauthParams, approved: true, approvedScopes }
|
||||
: { ...oauthParams, approved: false };
|
||||
|
||||
try {
|
||||
const res = await approveOAuthAuthorization(payload);
|
||||
window.location.replace(res.redirectUrl);
|
||||
} catch (err) {
|
||||
if (errorStatus(err) === 401) {
|
||||
window.location.replace(
|
||||
loginRedirectUrl(location.pathname, location.search),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSubmitting(null);
|
||||
notifications.show({
|
||||
message: errorText(err) || t("Something went wrong. Please try again."),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAccount() {
|
||||
if (submitting !== null) {
|
||||
return;
|
||||
}
|
||||
setCurrentUser(RESET);
|
||||
try {
|
||||
await logout();
|
||||
} finally {
|
||||
window.location.replace(
|
||||
loginRedirectUrl(location.pathname, location.search),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={3} ta="center" fw={600}>
|
||||
{t("{{name}} wants to access {{workspace}}", {
|
||||
name: info.clientName,
|
||||
workspace: currentUser.workspace.name,
|
||||
})}
|
||||
</Title>
|
||||
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<UserInfo user={currentUser.user} />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
disabled={submitting !== null}
|
||||
onClick={switchAccount}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Not you? Switch account")}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("This application will be able to:")}
|
||||
</Text>
|
||||
{scopeRows.map((row) => {
|
||||
const requested = info.scopes.includes(row.scope);
|
||||
return (
|
||||
<Checkbox
|
||||
key={row.scope}
|
||||
size="sm"
|
||||
checked={approvedScopes.includes(row.scope)}
|
||||
disabled={!requested || submitting !== null}
|
||||
onChange={(event) =>
|
||||
toggleScope(row.scope, event.currentTarget.checked)
|
||||
}
|
||||
label={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{row.icon}
|
||||
<Text size="sm" fw={500}>
|
||||
{row.label}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
description={row.description}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("You will be redirected to")}
|
||||
</Text>
|
||||
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
|
||||
{info.redirectUri}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!info.verified && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
py="xs"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
>
|
||||
{t("Make sure you trust this application before authorizing it.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => submitDecision(false)}
|
||||
loading={submitting === "deny"}
|
||||
disabled={submitting === "approve"}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => submitDecision(true)}
|
||||
loading={submitting === "approve"}
|
||||
disabled={approvedScopes.length === 0 || submitting === "deny"}
|
||||
>
|
||||
{t("Authorize")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getOAuthGrants,
|
||||
revokeOAuthGrant,
|
||||
} from "@/ee/oauth/services/oauth-service";
|
||||
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useOAuthGrantsQuery(): UseQueryResult<IOAuthGrant[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ["oauth-grants"],
|
||||
queryFn: () => getOAuthGrants(),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeOAuthGrantMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (grantId) => revokeOAuthGrant(grantId),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Access revoked") });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
["oauth-grants"].includes(item.queryKey[0] as string),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Something went wrong. Please try again."),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IApproveAuthorizationPayload,
|
||||
IAuthorizeParams,
|
||||
IOAuthAuthorizeInfo,
|
||||
IOAuthGrant,
|
||||
} from "@/ee/oauth/types/oauth.types";
|
||||
|
||||
export async function getOAuthAuthorizeInfo(
|
||||
params: IAuthorizeParams,
|
||||
): Promise<IOAuthAuthorizeInfo> {
|
||||
const req = await api.post<IOAuthAuthorizeInfo>(
|
||||
"/oauth/authorize-info",
|
||||
params,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function approveOAuthAuthorization(
|
||||
payload: IApproveAuthorizationPayload,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
const req = await api.post<{ redirectUrl: string }>(
|
||||
"/oauth/authorize",
|
||||
payload,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getOAuthGrants(): Promise<IOAuthGrant[]> {
|
||||
const req = await api.post<IOAuthGrant[]>("/oauth/grants", {});
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function revokeOAuthGrant(grantId: string): Promise<void> {
|
||||
await api.post("/oauth/grants/revoke", { grantId });
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
export interface IOAuthAuthorizeInfo {
|
||||
clientName: string;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
clientCreatedAt: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface IOAuthGrant {
|
||||
id: string;
|
||||
clientName: string;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
export type IAuthorizeParams = Record<string, string>;
|
||||
|
||||
export type IApproveAuthorizationPayload = {
|
||||
[param: string]: unknown;
|
||||
approved: boolean;
|
||||
approvedScopes?: string[];
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISiemDestination } from "@/ee/siem/types/siem.types";
|
||||
import { useDeleteSiemDestinationMutation } from "@/ee/siem/queries/siem-query";
|
||||
|
||||
interface DeleteDestinationModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
destination: ISiemDestination | null;
|
||||
}
|
||||
|
||||
export function DeleteDestinationModal({ opened, onClose, destination }: DeleteDestinationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const deleteMutation = useDeleteSiemDestinationMutation();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!destination) return;
|
||||
await deleteMutation.mutateAsync({ destinationId: destination.id });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t("Delete destination")}
|
||||
size="md"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t("Are you sure you want to delete the destination")}{" "}
|
||||
<strong>{destination?.name}</strong>?
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
|
||||
<Button color="red" onClick={handleDelete} loading={deleteMutation.isPending}>
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { DATADOG_SITES, ISiemDestination, ISiemTestResult } from "@/ee/siem/types/siem.types";
|
||||
import {
|
||||
useCreateSiemDestinationMutation,
|
||||
useTestSiemDestinationMutation,
|
||||
useUpdateSiemDestinationMutation,
|
||||
} from "@/ee/siem/queries/siem-query";
|
||||
import {
|
||||
DestinationFormValues,
|
||||
initialValues,
|
||||
toPayload,
|
||||
validateForm,
|
||||
} from "@/ee/siem/lib/destination-form";
|
||||
import { DESTINATION_TYPE_LABELS } from "./destination-table";
|
||||
|
||||
interface DestinationFormModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
destination?: ISiemDestination | null;
|
||||
}
|
||||
|
||||
function connectionKey(values: DestinationFormValues): string {
|
||||
const { type, config, secrets } = toPayload(values);
|
||||
return JSON.stringify({ type, config, secrets });
|
||||
}
|
||||
|
||||
export function DestinationFormModal({ opened, onClose, destination }: DestinationFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = Boolean(destination);
|
||||
const hasSecrets = destination?.hasSecrets ?? {};
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [testState, setTestState] = useState<{ result: ISiemTestResult | null; testedPayloadKey: string | null }>({
|
||||
result: null,
|
||||
testedPayloadKey: null,
|
||||
});
|
||||
const createMutation = useCreateSiemDestinationMutation();
|
||||
const updateMutation = useUpdateSiemDestinationMutation();
|
||||
const testMutation = useTestSiemDestinationMutation();
|
||||
|
||||
const form = useForm<DestinationFormValues>({
|
||||
initialValues: initialValues(destination),
|
||||
validate: (values) => validateForm(values, hasSecrets),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
form.setValues(initialValues(destination));
|
||||
form.resetDirty();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTestState({ result: null, testedPayloadKey: null });
|
||||
setAdvancedOpen(false);
|
||||
}
|
||||
}, [opened, destination?.id]);
|
||||
|
||||
const handleSubmit = async (values: DestinationFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
try {
|
||||
if (destination) {
|
||||
await updateMutation.mutateAsync({
|
||||
destinationId: destination.id,
|
||||
name: payload.name,
|
||||
config: payload.config,
|
||||
secrets: payload.secrets,
|
||||
enabled: payload.enabled,
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (form.validate().hasErrors) return;
|
||||
const payload = toPayload(form.values);
|
||||
const testedPayloadKey = connectionKey(form.values);
|
||||
setTestState((prev) => ({ ...prev, testedPayloadKey }));
|
||||
try {
|
||||
const result = await testMutation.mutateAsync({
|
||||
type: payload.type,
|
||||
config: payload.config,
|
||||
secrets: payload.secrets,
|
||||
destinationId: destination?.id,
|
||||
});
|
||||
setTestState({ result, testedPayloadKey });
|
||||
} catch {
|
||||
setTestState({ result: null, testedPayloadKey });
|
||||
}
|
||||
};
|
||||
|
||||
const type = form.values.type;
|
||||
const showTls = type !== "datadog";
|
||||
const currentPayloadKey = connectionKey(form.values);
|
||||
const showTestResult = testState.result !== null && testState.testedPayloadKey === currentPayloadKey;
|
||||
const connectionChanged = currentPayloadKey !== connectionKey(initialValues(destination));
|
||||
const testPassed = showTestResult && testState.result.delivered;
|
||||
const requiresTest = (!isEdit || connectionChanged) && !testPassed;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={isEdit ? t("Edit destination") : t("Add destination")}
|
||||
size="lg"
|
||||
closeButtonProps={{ "aria-label": t("Close") }}
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t("Preset")}
|
||||
data={[
|
||||
{ value: "splunk_hec", label: DESTINATION_TYPE_LABELS.splunk_hec },
|
||||
{ value: "datadog", label: DESTINATION_TYPE_LABELS.datadog },
|
||||
{ value: "http", label: DESTINATION_TYPE_LABELS.http },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
disabled={isEdit}
|
||||
{...form.getInputProps("type")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("Name")}
|
||||
placeholder={t("e.g. Splunk prod")}
|
||||
required
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
{type === "splunk_hec" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("HEC URL")}
|
||||
placeholder="https://splunk.example.com:8088"
|
||||
required
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("HEC token")}
|
||||
required={!hasSecrets.token}
|
||||
{...form.getInputProps("token")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "datadog" && (
|
||||
<>
|
||||
<Select
|
||||
label={t("Datadog site")}
|
||||
data={DATADOG_SITES.map((site) => ({ value: site, label: site }))}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps("site")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("API key")}
|
||||
required={!hasSecrets.apiKey}
|
||||
{...form.getInputProps("apiKey")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("Endpoint URL")}
|
||||
placeholder="https://collector.example.com/docmost"
|
||||
required
|
||||
{...form.getInputProps("url")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("Token")}
|
||||
description={t("Sent in the auth header below. Leave empty if your receiver does not need one.")}
|
||||
{...form.getInputProps("token")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{advancedOpen ? t("Hide advanced options") : t("Show advanced options")}
|
||||
</Button>
|
||||
|
||||
<Collapse expanded={advancedOpen}>
|
||||
<Stack gap="md">
|
||||
{type === "splunk_hec" && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t("Index")}
|
||||
description={t("Leave empty to use the token's default index")}
|
||||
{...form.getInputProps("index")}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput label={t("Source")} {...form.getInputProps("source")} />
|
||||
<TextInput label={t("Sourcetype")} {...form.getInputProps("sourcetype")} />
|
||||
</Group>
|
||||
<TextInput
|
||||
label={t("Host")}
|
||||
description={t("Defaults to this instance's hostname")}
|
||||
{...form.getInputProps("host")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "datadog" && (
|
||||
<>
|
||||
<TextInput label={t("Service")} {...form.getInputProps("service")} />
|
||||
<TextInput label={t("Tags")} placeholder="env:prod,team:security" {...form.getInputProps("tags")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "http" && (
|
||||
<>
|
||||
<Group grow>
|
||||
<TextInput label={t("Auth header name")} {...form.getInputProps("authHeaderName")} />
|
||||
<TextInput label={t("Auth header prefix")} {...form.getInputProps("authHeaderPrefix")} />
|
||||
</Group>
|
||||
<Select
|
||||
label={t("Body format")}
|
||||
data={[
|
||||
{ value: "json", label: t("JSON array") },
|
||||
{ value: "ndjson", label: "NDJSON" },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
{...form.getInputProps("format")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTls && (
|
||||
<>
|
||||
{!isCloud() && (
|
||||
<Switch
|
||||
label={t("Verify TLS certificate")}
|
||||
description={
|
||||
form.values.rejectUnauthorized
|
||||
? undefined
|
||||
: t("Insecure: connections can be intercepted.")
|
||||
}
|
||||
{...form.getInputProps("rejectUnauthorized", { type: "checkbox" })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Switch label={t("Enabled")} {...form.getInputProps("enabled", { type: "checkbox" })} />
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
{showTestResult && (
|
||||
<Alert
|
||||
color={testState.result.delivered ? "green" : "red"}
|
||||
icon={testState.result.delivered ? <IconCheck size={16} /> : <IconAlertCircle size={16} />}
|
||||
>
|
||||
{testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="space-between" mt="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={handleTest} loading={testMutation.isPending}>
|
||||
{t("Test connection")}
|
||||
</Button>
|
||||
{requiresTest && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Test the connection before saving.")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Group>
|
||||
<Button variant="default" onClick={onClose}>{t("Cancel")}</Button>
|
||||
<Button type="submit" disabled={requiresTest} loading={createMutation.isPending || updateMutation.isPending}>
|
||||
{isEdit ? t("Save") : t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISiemDestination } from "@/ee/siem/types/siem.types";
|
||||
|
||||
export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!destination.enabled) {
|
||||
return <Badge color="gray" variant="light">{t("Disabled")}</Badge>;
|
||||
}
|
||||
if (destination.status === "failing") {
|
||||
return <Badge color="red" variant="light">{t("Failing")}</Badge>;
|
||||
}
|
||||
return <Badge color="green" variant="light">{t("Healthy")}</Badge>;
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconPlugConnected,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||
import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types";
|
||||
import { DestinationStatusBadge } from "./destination-status-badge";
|
||||
|
||||
export const DESTINATION_TYPE_LABELS: Record<SiemDestinationType, string> = {
|
||||
splunk_hec: "Splunk HEC",
|
||||
datadog: "Datadog",
|
||||
http: "Generic HTTP",
|
||||
};
|
||||
|
||||
interface DestinationTableProps {
|
||||
destinations?: ISiemDestination[];
|
||||
isLoading?: boolean;
|
||||
onEdit: (destination: ISiemDestination) => void;
|
||||
onTest: (destination: ISiemDestination) => void;
|
||||
onRetry: (destination: ISiemDestination) => void;
|
||||
onDelete: (destination: ISiemDestination) => void;
|
||||
onToggle: (destination: ISiemDestination, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function DestinationTable({
|
||||
destinations,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onTest,
|
||||
onRetry,
|
||||
onDelete,
|
||||
onToggle,
|
||||
}: DestinationTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={760}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
<Table.Th>{t("Type")}</Table.Th>
|
||||
<Table.Th>{t("Enabled")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th>{t("Last delivered")}</Table.Th>
|
||||
<Table.Th>{t("Last error")}</Table.Th>
|
||||
<Table.Th aria-label={t("Actions")} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{destinations && destinations.length > 0 ? (
|
||||
destinations.map((destination) => (
|
||||
<Table.Tr key={destination.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>{destination.name}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm">{DESTINATION_TYPE_LABELS[destination.type]}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={destination.enabled}
|
||||
onChange={(event) => onToggle(destination, event.currentTarget.checked)}
|
||||
aria-label={t("Enabled")}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DestinationStatusBadge destination={destination} />
|
||||
{destination.failingSince &&
|
||||
(destination.status === "failing" ||
|
||||
!destination.enabled) && (
|
||||
<Text
|
||||
fz="xs"
|
||||
c="dimmed"
|
||||
mt={4}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{t("Failing since {{time}}", {
|
||||
time: formattedDate(
|
||||
new Date(destination.failingSince),
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{destination.lastDeliveredAt
|
||||
? timeAgo(new Date(destination.lastDeliveredAt))
|
||||
: t("Never")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{destination.lastError ? (
|
||||
<Tooltip label={destination.lastError} multiline w={320}>
|
||||
<Text fz="xs" c="red" lineClamp={2} style={{ maxWidth: 260 }}>
|
||||
{destination.lastError}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text fz="xs" c="dimmed">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label={t("Actions")}>
|
||||
<IconDots size={18} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={16} />} onClick={() => onEdit(destination)}>
|
||||
{t("Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconPlugConnected size={16} />} onClick={() => onTest(destination)}>
|
||||
{t("Send test event")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => onRetry(destination)}
|
||||
disabled={!destination.nextAttemptAt}
|
||||
>
|
||||
{t("Retry now")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" leftSection={<IconTrash size={16} />} onClick={() => onDelete(destination)}>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
!isLoading && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text fz="sm" c="dimmed" ta="center" py="md">
|
||||
{t("No destinations yet")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Tooltip } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||
import { Feature } from "@/ee/features";
|
||||
import {
|
||||
ISiemDestination,
|
||||
SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
import {
|
||||
useRetrySiemDestinationMutation,
|
||||
useSiemDestinationsQuery,
|
||||
extractErrorMessage,
|
||||
useTestSiemDestinationMutation,
|
||||
useUpdateSiemDestinationMutation,
|
||||
} from "@/ee/siem/queries/siem-query";
|
||||
import { DestinationTable } from "@/ee/siem/components/destination-table";
|
||||
import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal";
|
||||
import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal";
|
||||
|
||||
export default function SiemStreamingPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner } = useUserRole();
|
||||
const hasFeature = useHasFeature(Feature.SIEM);
|
||||
const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature);
|
||||
const updateMutation = useUpdateSiemDestinationMutation();
|
||||
const retryMutation = useRetrySiemDestinationMutation();
|
||||
const testMutation = useTestSiemDestinationMutation();
|
||||
const [formOpened, setFormOpened] = useState(false);
|
||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||
const [selected, setSelected] = useState<ISiemDestination | null>(null);
|
||||
|
||||
if (!isOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const atDestinationLimit =
|
||||
(data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE;
|
||||
|
||||
const handleTest = async (destination: ISiemDestination) => {
|
||||
const result = await testMutation
|
||||
.mutateAsync({
|
||||
type: destination.type,
|
||||
config: destination.config as unknown as Record<string, unknown>,
|
||||
destinationId: destination.id,
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!result) return;
|
||||
notifications.show({
|
||||
message: result.delivered
|
||||
? t("Test event delivered to {{name}}", { name: destination.name })
|
||||
: result.error,
|
||||
color: result.delivered ? "green" : "red",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasFeature && (
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="yellow" mb="md">
|
||||
{t("SIEM streaming requires an Enterprise license.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mb="md">
|
||||
<Tooltip
|
||||
label={t("Maximum of {{limit}} destinations reached", {
|
||||
limit: SIEM_MAX_DESTINATIONS_PER_WORKSPACE,
|
||||
})}
|
||||
disabled={!atDestinationLimit}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setFormOpened(true);
|
||||
}}
|
||||
disabled={!hasFeature || atDestinationLimit}
|
||||
>
|
||||
{t("Add destination")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{isError && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="md">
|
||||
{t("Could not load SIEM destinations: {{message}}", {
|
||||
message: extractErrorMessage(error),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFeature && !isError && (
|
||||
<DestinationTable
|
||||
destinations={data}
|
||||
isLoading={isLoading}
|
||||
onEdit={(destination) => {
|
||||
setSelected(destination);
|
||||
setFormOpened(true);
|
||||
}}
|
||||
onTest={handleTest}
|
||||
onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })}
|
||||
onDelete={(destination) => {
|
||||
setSelected(destination);
|
||||
setDeleteOpened(true);
|
||||
}}
|
||||
onToggle={(destination, enabled) =>
|
||||
updateMutation.mutate({ destinationId: destination.id, enabled })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DestinationFormModal
|
||||
opened={formOpened}
|
||||
onClose={() => setFormOpened(false)}
|
||||
destination={selected}
|
||||
/>
|
||||
<DeleteDestinationModal
|
||||
opened={deleteOpened}
|
||||
onClose={() => setDeleteOpened(false)}
|
||||
destination={selected}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import {
|
||||
DATADOG_SITES,
|
||||
ISiemDestination,
|
||||
ISiemDestinationInput,
|
||||
SiemDestinationType,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
|
||||
export type DestinationFormValues = {
|
||||
name: string;
|
||||
type: SiemDestinationType;
|
||||
url: string;
|
||||
token: string;
|
||||
apiKey: string;
|
||||
authHeaderName: string;
|
||||
authHeaderPrefix: string;
|
||||
format: "json" | "ndjson";
|
||||
index: string;
|
||||
source: string;
|
||||
sourcetype: string;
|
||||
host: string;
|
||||
site: string;
|
||||
service: string;
|
||||
tags: string;
|
||||
rejectUnauthorized: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_FORM_VALUES: DestinationFormValues = {
|
||||
name: "",
|
||||
type: "splunk_hec",
|
||||
url: "",
|
||||
token: "",
|
||||
apiKey: "",
|
||||
authHeaderName: "Authorization",
|
||||
authHeaderPrefix: "Bearer ",
|
||||
format: "json",
|
||||
index: "",
|
||||
source: "docmost",
|
||||
sourcetype: "docmost:audit",
|
||||
host: "",
|
||||
site: DATADOG_SITES[0],
|
||||
service: "docmost",
|
||||
tags: "",
|
||||
rejectUnauthorized: true,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/;
|
||||
export const SECRET_MASK = "********";
|
||||
|
||||
export function initialValues(
|
||||
destination?: ISiemDestination | null,
|
||||
): DestinationFormValues {
|
||||
if (!destination) return { ...DEFAULT_FORM_VALUES };
|
||||
const config = destination.config as Record<string, any>;
|
||||
const tls = config.tls ?? {};
|
||||
return {
|
||||
...DEFAULT_FORM_VALUES,
|
||||
name: destination.name,
|
||||
type: destination.type,
|
||||
enabled: destination.enabled,
|
||||
token: destination.hasSecrets?.token ? SECRET_MASK : "",
|
||||
apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "",
|
||||
url: config.url ?? "",
|
||||
authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName,
|
||||
authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix,
|
||||
format: config.format ?? "json",
|
||||
index: config.index ?? "",
|
||||
source: config.source ?? DEFAULT_FORM_VALUES.source,
|
||||
sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype,
|
||||
host: config.host ?? "",
|
||||
site: config.site ?? DEFAULT_FORM_VALUES.site,
|
||||
service: config.service ?? DEFAULT_FORM_VALUES.service,
|
||||
tags: config.tags ?? "",
|
||||
rejectUnauthorized: tls.rejectUnauthorized ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateForm(
|
||||
values: DestinationFormValues,
|
||||
hasSecrets: Record<string, boolean> = {},
|
||||
): Partial<Record<keyof DestinationFormValues, string>> {
|
||||
const errors: Partial<Record<keyof DestinationFormValues, string>> = {};
|
||||
|
||||
if (!values.name.trim()) errors.name = "Name is required";
|
||||
|
||||
if (values.type !== "datadog" && !isValidUrl(values.url.trim())) {
|
||||
errors.url = "Enter a valid http(s) URL";
|
||||
}
|
||||
|
||||
if (values.type === "splunk_hec") {
|
||||
if (!values.token && !hasSecrets.token) {
|
||||
errors.token = "HEC token is required";
|
||||
}
|
||||
try {
|
||||
const url = new URL(values.url.trim());
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") {
|
||||
errors.url = "Enter the HEC base URL or the /services/collector/event endpoint";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (values.type === "datadog") {
|
||||
if (!(DATADOG_SITES as readonly string[]).includes(values.site)) {
|
||||
errors.site = "Select a Datadog site";
|
||||
}
|
||||
if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required";
|
||||
}
|
||||
|
||||
if (values.type === "http") {
|
||||
if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) {
|
||||
errors.authHeaderName = "Use letters, digits and hyphens only";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function enteredSecret(key: string, value: string): Record<string, string> {
|
||||
const trimmed = value.trim();
|
||||
return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {};
|
||||
}
|
||||
|
||||
export function toPayload(values: DestinationFormValues): ISiemDestinationInput {
|
||||
const tls = { rejectUnauthorized: values.rejectUnauthorized };
|
||||
|
||||
let config: Record<string, unknown>;
|
||||
let secrets: Record<string, string>;
|
||||
|
||||
switch (values.type) {
|
||||
case "splunk_hec":
|
||||
config = {
|
||||
url: values.url.trim(),
|
||||
index: values.index.trim(),
|
||||
source: values.source.trim() || "docmost",
|
||||
sourcetype: values.sourcetype.trim() || "docmost:audit",
|
||||
host: values.host.trim(),
|
||||
tls,
|
||||
};
|
||||
secrets = enteredSecret("token", values.token);
|
||||
break;
|
||||
case "datadog":
|
||||
config = {
|
||||
site: values.site,
|
||||
service: values.service.trim() || "docmost",
|
||||
tags: values.tags.trim(),
|
||||
};
|
||||
secrets = enteredSecret("apiKey", values.apiKey);
|
||||
break;
|
||||
default:
|
||||
config = {
|
||||
url: values.url.trim(),
|
||||
authHeaderName: values.authHeaderName.trim(),
|
||||
authHeaderPrefix: values.authHeaderPrefix,
|
||||
format: values.format,
|
||||
tls,
|
||||
};
|
||||
secrets = enteredSecret("token", values.token);
|
||||
}
|
||||
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
type: values.type,
|
||||
config,
|
||||
secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")),
|
||||
enabled: values.enabled,
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createSiemDestination,
|
||||
deleteSiemDestination,
|
||||
getSiemDestinations,
|
||||
retrySiemDestination,
|
||||
testSiemDestination,
|
||||
updateSiemDestination,
|
||||
} from "@/ee/siem/services/siem-service";
|
||||
import {
|
||||
ISiemDestination,
|
||||
ISiemDestinationInput,
|
||||
ISiemTestResult,
|
||||
ITestSiemDestinationInput,
|
||||
IUpdateSiemDestinationInput,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
|
||||
export const SIEM_DESTINATIONS_KEY = ["siem-destinations"];
|
||||
|
||||
export function extractErrorMessage(error: Error): string {
|
||||
const data = (error as any)?.response?.data;
|
||||
const message = data?.message ?? error.message;
|
||||
return Array.isArray(message) ? message.join(", ") : String(message);
|
||||
}
|
||||
|
||||
function showError(error: Error) {
|
||||
notifications.show({ message: extractErrorMessage(error), color: "red" });
|
||||
}
|
||||
|
||||
function isForbidden(error: unknown): boolean {
|
||||
return (error as { response?: { status?: number } })?.response?.status === 403;
|
||||
}
|
||||
|
||||
export function useSiemDestinationsQuery(
|
||||
enabled = true,
|
||||
): UseQueryResult<ISiemDestination[], Error> {
|
||||
return useQuery({
|
||||
queryKey: SIEM_DESTINATIONS_KEY,
|
||||
queryFn: getSiemDestinations,
|
||||
enabled,
|
||||
retry: (failureCount, error) => !isForbidden(error) && failureCount < 2,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? false : 15_000),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateDestinations() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY });
|
||||
}
|
||||
|
||||
export function useCreateSiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<ISiemDestination, Error, ISiemDestinationInput>({
|
||||
mutationFn: createSiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Destination created") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<ISiemDestination, Error, IUpdateSiemDestinationInput>({
|
||||
mutationFn: updateSiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Destination updated") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<void, Error, { destinationId: string }>({
|
||||
mutationFn: deleteSiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Destination deleted") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetrySiemDestinationMutation() {
|
||||
const { t } = useTranslation();
|
||||
const invalidate = useInvalidateDestinations();
|
||||
return useMutation<void, Error, { destinationId: string }>({
|
||||
mutationFn: retrySiemDestination,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Retry scheduled") });
|
||||
invalidate();
|
||||
},
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestSiemDestinationMutation() {
|
||||
return useMutation<ISiemTestResult, Error, ITestSiemDestinationInput>({
|
||||
mutationFn: testSiemDestination,
|
||||
onError: showError,
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
ISiemDestination,
|
||||
ISiemDestinationInput,
|
||||
ISiemTestResult,
|
||||
ITestSiemDestinationInput,
|
||||
IUpdateSiemDestinationInput,
|
||||
} from "@/ee/siem/types/siem.types";
|
||||
|
||||
export async function getSiemDestinations(): Promise<ISiemDestination[]> {
|
||||
const req = await api.post<ISiemDestination[]>("/siem/destinations");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function createSiemDestination(
|
||||
data: ISiemDestinationInput,
|
||||
): Promise<ISiemDestination> {
|
||||
const req = await api.post<ISiemDestination>("/siem/destinations/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateSiemDestination(
|
||||
data: IUpdateSiemDestinationInput,
|
||||
): Promise<ISiemDestination> {
|
||||
const req = await api.post<ISiemDestination>("/siem/destinations/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function deleteSiemDestination(data: {
|
||||
destinationId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/siem/destinations/delete", data);
|
||||
}
|
||||
|
||||
export async function testSiemDestination(
|
||||
data: ITestSiemDestinationInput,
|
||||
): Promise<ISiemTestResult> {
|
||||
const req = await api.post<ISiemTestResult>("/siem/destinations/test", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function retrySiemDestination(data: {
|
||||
destinationId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/siem/destinations/retry", data);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2;
|
||||
|
||||
export type SiemDestinationType = "http" | "splunk_hec" | "datadog";
|
||||
export type SiemDestinationStatus = "healthy" | "failing";
|
||||
|
||||
export const DATADOG_SITES = [
|
||||
"datadoghq.com",
|
||||
"datadoghq.eu",
|
||||
"us3.datadoghq.com",
|
||||
"us5.datadoghq.com",
|
||||
"ap1.datadoghq.com",
|
||||
"ddog-gov.com",
|
||||
] as const;
|
||||
|
||||
export interface ITlsOptions {
|
||||
rejectUnauthorized: boolean;
|
||||
}
|
||||
|
||||
export interface IHttpConfig {
|
||||
url: string;
|
||||
authHeaderName: string;
|
||||
authHeaderPrefix: string;
|
||||
format: "json" | "ndjson";
|
||||
tls?: ITlsOptions;
|
||||
}
|
||||
|
||||
export interface ISplunkHecConfig {
|
||||
url: string;
|
||||
index?: string;
|
||||
source: string;
|
||||
sourcetype: string;
|
||||
host?: string;
|
||||
channelId: string;
|
||||
tls?: ITlsOptions;
|
||||
}
|
||||
|
||||
export interface IDatadogConfig {
|
||||
site: string;
|
||||
service: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig;
|
||||
|
||||
export interface ISiemDestination {
|
||||
id: string;
|
||||
name: string;
|
||||
type: SiemDestinationType;
|
||||
enabled: boolean;
|
||||
status: SiemDestinationStatus;
|
||||
config: ISiemConfig;
|
||||
hasSecrets: Record<string, boolean>;
|
||||
cursorCreatedAt: string;
|
||||
lastDeliveredAt: string | null;
|
||||
lastError: string | null;
|
||||
lastErrorAt: string | null;
|
||||
consecutiveFailures: number;
|
||||
nextAttemptAt: string | null;
|
||||
failingSince: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ISiemDestinationInput {
|
||||
name: string;
|
||||
type: SiemDestinationType;
|
||||
config: Record<string, unknown>;
|
||||
secrets?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IUpdateSiemDestinationInput {
|
||||
destinationId: string;
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
secrets?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ITestSiemDestinationInput {
|
||||
type: SiemDestinationType;
|
||||
config: Record<string, unknown>;
|
||||
secrets?: Record<string, string>;
|
||||
destinationId?: string;
|
||||
}
|
||||
|
||||
export interface ISiemTestResult {
|
||||
delivered: boolean;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import Lightbox, { type Slide } from "yet-another-react-lightbox";
|
||||
import type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import Download from "yet-another-react-lightbox/plugins/download";
|
||||
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
|
||||
import Video from "yet-another-react-lightbox/plugins/video";
|
||||
@@ -51,11 +53,13 @@ function getFilename(src: string) {
|
||||
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
|
||||
const src = getFileUrl(rawSrc);
|
||||
const filename = getFilename(rawSrc);
|
||||
const caption = alt || filename;
|
||||
|
||||
if (type === "video") {
|
||||
return {
|
||||
type: "video",
|
||||
sources: [{ src, type: getVideoMimeType(rawSrc) }],
|
||||
title: caption,
|
||||
download: { url: src, filename },
|
||||
};
|
||||
} else {
|
||||
@@ -63,6 +67,7 @@ function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
|
||||
type: "image",
|
||||
src,
|
||||
alt: alt || undefined,
|
||||
title: caption,
|
||||
download: { url: src, filename },
|
||||
};
|
||||
}
|
||||
@@ -119,11 +124,6 @@ export default function LightboxView({
|
||||
|
||||
const [pageSlides, setPageSlides] = useState<Slide[]>([]);
|
||||
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setIsFullscreen(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -161,21 +161,9 @@ export default function LightboxView({
|
||||
close={onClose}
|
||||
index={index}
|
||||
slides={slides}
|
||||
plugins={[Download, Fullscreen, Video, Zoom]}
|
||||
styles={{
|
||||
container: { backgroundColor: "rgba(0, 0, 0, 0.8)" },
|
||||
icon: { width: 24, height: 24 },
|
||||
toolbar: {
|
||||
margin: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
}}
|
||||
controller={{ closeOnBackdropClick: !isFullscreen }}
|
||||
on={{
|
||||
enterFullscreen: () => setIsFullscreen(true),
|
||||
exitFullscreen: () => setIsFullscreen(false),
|
||||
}}
|
||||
plugins={[Captions, Download, Fullscreen, Video, Zoom]}
|
||||
styles={{ container: { backgroundColor: "rgba(0, 0, 0, 0.8)" } }}
|
||||
captions={{ descriptionTextAlign: "center" }}
|
||||
video={{ controls: true, playsInline: true }}
|
||||
zoom={{
|
||||
scrollToZoom: true,
|
||||
|
||||
@@ -38,60 +38,6 @@
|
||||
line-height: var(--mantine-line-height-md);
|
||||
}
|
||||
|
||||
.media-alt-badge {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 1;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
background-color: var(--mantine-color-body);
|
||||
|
||||
@mixin light {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border: 1px solid var(--mantine-color-dark-4);
|
||||
color: var(--mantine-color-dark-1);
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: attr(data-alt);
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
width: max-content;
|
||||
max-width: 320px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--mantine-radius-default);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
line-height: var(--mantine-line-height-sm);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
text-align: start;
|
||||
background-color: var(--mantine-color-gray-9);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.media-pulse {
|
||||
animation: media-pulse 1.2s ease-in-out infinite;
|
||||
|
||||
|
||||
@@ -12,19 +12,13 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||
import NoTableResults from "@/components/common/no-table-results.tsx";
|
||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
|
||||
export default function GroupMembersList() {
|
||||
const { t } = useTranslation();
|
||||
const { groupId } = useParams();
|
||||
const { search, cursor, goNext, goPrev, handleSearch } =
|
||||
usePaginateAndSearch();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId, {
|
||||
cursor,
|
||||
query: search,
|
||||
});
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data, isLoading } = useGroupMembersQuery(groupId, { cursor });
|
||||
const removeGroupMember = useRemoveGroupMemberMutation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
@@ -54,7 +48,6 @@ export default function GroupMembersList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchInput onSearch={handleSearch} />
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
@@ -66,62 +59,55 @@ export default function GroupMembersList() {
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.length > 0 ? (
|
||||
data?.items.map((user: IUser, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar
|
||||
avatarUrl={user.avatarUrl}
|
||||
name={user.name}
|
||||
/>
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: user.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
{t("Remove group member")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
<NoTableResults colSpan={3} />
|
||||
)}
|
||||
{data?.items.map((user: IUser, index: number) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
|
||||
<div>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{user.email}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{t("Active")}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{isAdmin && (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
aria-label={t("Member actions for {{name}}", {
|
||||
name: user.name,
|
||||
})}
|
||||
>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => openRemoveModal(user.id)}>
|
||||
{t("Remove group member")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -63,12 +63,6 @@ export function NotificationItem({
|
||||
return "Page verification expires soon";
|
||||
case "page.verification_expired":
|
||||
return "Page verification has expired";
|
||||
case "siem_destination.failing":
|
||||
return "SIEM destination <bold>{{name}}</bold> is failing";
|
||||
case "siem_destination.disabled":
|
||||
return "SIEM destination <bold>{{name}}</bold> was disabled after 24 hours of failures";
|
||||
case "siem_destination.recovered":
|
||||
return "SIEM destination <bold>{{name}}</bold> recovered";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -83,19 +77,6 @@ export function NotificationItem({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const isSiemDestination = notification.type.startsWith("siem_destination.");
|
||||
const destinationName =
|
||||
typeof notification.data?.destinationName === "string"
|
||||
? notification.data.destinationName
|
||||
: "";
|
||||
const lastError =
|
||||
(notification.type === "siem_destination.failing" ||
|
||||
notification.type === "siem_destination.disabled") &&
|
||||
typeof notification.data?.lastError === "string"
|
||||
? notification.data.lastError
|
||||
: null;
|
||||
const linkUrl = isSiemDestination ? "/settings/audit/siem" : pageUrl;
|
||||
|
||||
const markReadIfNeeded = () => {
|
||||
if (isUnread) {
|
||||
markRead.mutate([notification.id]);
|
||||
@@ -116,7 +97,7 @@ export function NotificationItem({
|
||||
return (
|
||||
<UnstyledButton
|
||||
component={Link}
|
||||
to={linkUrl ?? ""}
|
||||
to={pageUrl ?? ""}
|
||||
onClick={handleClick}
|
||||
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
|
||||
// so that right-click (button 2, context menu) does not mark as read
|
||||
@@ -143,21 +124,11 @@ export function NotificationItem({
|
||||
<Text size="sm" lineClamp={2}>
|
||||
<Trans
|
||||
i18nKey={getNotificationMessageKey()}
|
||||
values={{
|
||||
name: isSiemDestination
|
||||
? destinationName
|
||||
: notification.actor?.name,
|
||||
}}
|
||||
values={{ name: notification.actor?.name }}
|
||||
components={{ bold: <Text span fw={600} /> }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{lastError && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1} mt={2}>
|
||||
{lastError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{notification.page && (
|
||||
<Group gap={4} mt={2} wrap="nowrap">
|
||||
{notification.page.icon ? (
|
||||
|
||||
@@ -9,10 +9,7 @@ export type NotificationType =
|
||||
| "page.verification_expired"
|
||||
| "page.verified"
|
||||
| "page.approval_requested"
|
||||
| "page.approval_rejected"
|
||||
| "siem_destination.failing"
|
||||
| "siem_destination.disabled"
|
||||
| "siem_destination.recovered";
|
||||
| "page.approval_rejected";
|
||||
|
||||
export type INotification = {
|
||||
id: string;
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface IWorkspace {
|
||||
mcpEnabled?: boolean;
|
||||
aiChatReadOnly?: boolean;
|
||||
aiChatWorkspaceKnowledgeOnly?: boolean;
|
||||
enforceMcpOauth?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
restrictApiToAdmins?: boolean;
|
||||
allowMemberTemplates?: boolean;
|
||||
@@ -53,7 +52,6 @@ export interface IWorkspaceAiSettings {
|
||||
search?: boolean;
|
||||
generative?: boolean;
|
||||
mcp?: boolean;
|
||||
enforceMcpOauth?: boolean;
|
||||
chat?: boolean;
|
||||
chatReadOnly?: boolean;
|
||||
chatWorkspaceKnowledgeOnly?: boolean;
|
||||
|
||||
@@ -76,8 +76,6 @@ function redirectToLogin() {
|
||||
APP_ROUTE.AUTH.MFA_CHALLENGE,
|
||||
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
|
||||
"/invites",
|
||||
// the oauth consent page redirects to login itself, preserving its query string
|
||||
"/oauth/consent",
|
||||
];
|
||||
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
|
||||
const redirectTo = window.location.pathname;
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"@fastify/cookie": "11.1.2",
|
||||
"@fastify/multipart": "10.1.1",
|
||||
"@fastify/static": "10.1.3",
|
||||
"@jmondi/oauth2-server": "^4.3.7",
|
||||
"@keyv/redis": "5.1.6",
|
||||
"@langchain/core": "1.1.46",
|
||||
"@langchain/textsplitters": "1.0.1",
|
||||
@@ -161,8 +160,7 @@
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts",
|
||||
"tsx"
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
@@ -182,7 +180,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
|
||||
|
||||
@@ -28,7 +28,6 @@ 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 = [];
|
||||
@@ -52,7 +51,7 @@ try {
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
LoggerModule,
|
||||
...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]),
|
||||
NoopAuditModule,
|
||||
CoreModule,
|
||||
DatabaseModule,
|
||||
EnvironmentModule,
|
||||
@@ -99,7 +98,6 @@ try {
|
||||
SecurityModule,
|
||||
TelemetryModule,
|
||||
ThrottleModule,
|
||||
OutboundModule,
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -63,7 +63,6 @@ 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,
|
||||
|
||||
@@ -14,8 +14,7 @@ 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, { defaultReconnectStrategy } from '@keyv/redis';
|
||||
import { parseRedisUrl } from '../../common/helpers';
|
||||
import KeyvRedis from '@keyv/redis';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,20 +33,10 @@ import { parseRedisUrl } from '../../common/helpers';
|
||||
isGlobal: true,
|
||||
useFactory: async (environmentService: EnvironmentService) => {
|
||||
const redisUrl = environmentService.getRedisUrl();
|
||||
const { family, tls } = parseRedisUrl(redisUrl);
|
||||
|
||||
return {
|
||||
ttl: 5 * 1000,
|
||||
stores: [
|
||||
new KeyvRedis({
|
||||
url: redisUrl,
|
||||
socket: {
|
||||
family,
|
||||
reconnectStrategy: defaultReconnectStrategy,
|
||||
...tls,
|
||||
},
|
||||
}),
|
||||
],
|
||||
stores: [new KeyvRedis(redisUrl)],
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const OAUTH_SCOPE_KEY = 'oauthScope';
|
||||
|
||||
export type OAuthRouteScope = 'read' | 'write';
|
||||
|
||||
export const OAuthScope = (scope: OAuthRouteScope) =>
|
||||
SetMetadata(OAUTH_SCOPE_KEY, scope);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const REQUIRE_SESSION_AUTH_KEY = 'requireSessionAuth';
|
||||
|
||||
export const RequireSessionAuth = () =>
|
||||
SetMetadata(REQUIRE_SESSION_AUTH_KEY, true);
|
||||
@@ -14,7 +14,6 @@ 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',
|
||||
@@ -24,11 +23,6 @@ export const AuditEvent = {
|
||||
API_KEY_UPDATED: 'api_key.updated',
|
||||
API_KEY_DELETED: 'api_key.deleted',
|
||||
|
||||
// OAuth
|
||||
OAUTH_CLIENT_REGISTERED: 'oauth_client.registered',
|
||||
OAUTH_GRANT_CREATED: 'oauth_grant.created',
|
||||
OAUTH_GRANT_REVOKED: 'oauth_grant.revoked',
|
||||
|
||||
// SCIM Tokens
|
||||
SCIM_TOKEN_CREATED: 'scim_token.created',
|
||||
SCIM_TOKEN_UPDATED: 'scim_token.updated',
|
||||
@@ -70,7 +64,6 @@ 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',
|
||||
@@ -106,16 +99,6 @@ 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];
|
||||
@@ -128,8 +111,7 @@ export const EXCLUDED_AUDIT_EVENTS: Set<string> = new Set([
|
||||
AuditEvent.COMMENT_UPDATED,
|
||||
AuditEvent.COMMENT_RESOLVED,
|
||||
AuditEvent.COMMENT_REOPENED,
|
||||
AuditEvent.ATTACHMENT_UPLOADED,
|
||||
AuditEvent.SIEM_DESTINATION_TEST,
|
||||
AuditEvent.ATTACHMENT_UPLOADED
|
||||
]);
|
||||
|
||||
export const AuditResource = {
|
||||
@@ -142,15 +124,11 @@ export const AuditResource = {
|
||||
COMMENT: 'comment',
|
||||
SHARE: 'share',
|
||||
API_KEY: 'api_key',
|
||||
OAUTH_CLIENT: 'oauth_client',
|
||||
OAUTH_GRANT: 'oauth_grant',
|
||||
SCIM_TOKEN: 'scim_token',
|
||||
SSO_PROVIDER: 'sso_provider',
|
||||
WORKSPACE_INVITATION: 'workspace_invitation',
|
||||
ATTACHMENT: 'attachment',
|
||||
LICENSE: 'license',
|
||||
SIEM_DESTINATION: 'siem_destination',
|
||||
TEMPLATE: 'template',
|
||||
} as const;
|
||||
|
||||
export type AuditResourceType =
|
||||
|
||||
@@ -12,8 +12,6 @@ export enum EventName {
|
||||
SPACE_UPDATED = 'space.updated',
|
||||
SPACE_DELETED = 'space.deleted',
|
||||
|
||||
USER_PASSWORD_RESET = 'user.password.reset',
|
||||
|
||||
WORKSPACE_CREATED = 'workspace.created',
|
||||
WORKSPACE_UPDATED = 'workspace.updated',
|
||||
WORKSPACE_DELETED = 'workspace.deleted',
|
||||
|
||||
@@ -23,10 +23,7 @@ export const Feature = {
|
||||
PERSONAL_SPACES: 'spaces:personal',
|
||||
DOCX_EXPORT: 'export:docx',
|
||||
BASES: 'bases',
|
||||
OAUTH: 'oauth',
|
||||
AI_CONTROLS: 'ai:controls',
|
||||
MCP_CONTROLS: 'mcp:controls',
|
||||
SIEM: 'siem',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { OAUTH_SCOPE_KEY } from '../decorators/oauth-scope.decorator';
|
||||
import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
|
||||
import { JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
|
||||
const handlerSentinel = () => 'handler';
|
||||
const classSentinel = class Controller {};
|
||||
|
||||
function createCtx(): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => handlerSentinel,
|
||||
getClass: () => classSentinel,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createGuard(scopeMetadata?: unknown, requireSession?: boolean) {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) =>
|
||||
key === REQUIRE_SESSION_AUTH_KEY ? requireSession : scopeMetadata,
|
||||
),
|
||||
} as any;
|
||||
const environmentService = {
|
||||
isCloud: jest.fn().mockReturnValue(false),
|
||||
} as any;
|
||||
const guard = new JwtAuthGuard(reflector, environmentService);
|
||||
return { guard, reflector };
|
||||
}
|
||||
|
||||
function oauthUser(scopes: string[]) {
|
||||
return {
|
||||
user: { id: 'user_1' },
|
||||
workspace: { id: 'ws_1' },
|
||||
oauth: { grantId: 'grant_1', scopes },
|
||||
};
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard.handleRequest', () => {
|
||||
it('rethrows the strategy error', () => {
|
||||
const { guard } = createGuard();
|
||||
const err = new UnauthorizedException('bad token');
|
||||
|
||||
expect(() => guard.handleRequest(err, null, null, createCtx())).toThrow(err);
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when there is no user', () => {
|
||||
const { guard } = createGuard();
|
||||
|
||||
expect(() => guard.handleRequest(null, null, null, createCtx())).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a non-oauth user untouched without consulting scope metadata', () => {
|
||||
const { guard, reflector } = createGuard();
|
||||
const user = { user: { id: 'user_1' }, workspace: { id: 'ws_1' } };
|
||||
|
||||
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
|
||||
expect(reflector.getAllAndOverride).not.toHaveBeenCalledWith(
|
||||
OAUTH_SCOPE_KEY,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids an oauth user on a route without scope metadata', () => {
|
||||
const { guard, reflector } = createGuard(undefined);
|
||||
|
||||
expect(() =>
|
||||
guard.handleRequest(null, oauthUser(['read', 'write']), null, createCtx()),
|
||||
).toThrow(ForbiddenException);
|
||||
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(OAUTH_SCOPE_KEY, [
|
||||
handlerSentinel,
|
||||
classSentinel,
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes read scope on a read route', () => {
|
||||
const { guard } = createGuard('read');
|
||||
const user = oauthUser(['read']);
|
||||
|
||||
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
|
||||
});
|
||||
|
||||
it('forbids read scope on a write route with insufficient_scope', () => {
|
||||
const { guard } = createGuard('write');
|
||||
|
||||
expect(() =>
|
||||
guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
|
||||
).toThrow('insufficient_scope');
|
||||
});
|
||||
|
||||
it('passes write scope on a read route', () => {
|
||||
const { guard } = createGuard('read');
|
||||
const user = oauthUser(['write']);
|
||||
|
||||
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
|
||||
});
|
||||
|
||||
it('passes write scope on a write route', () => {
|
||||
const { guard } = createGuard('write');
|
||||
const user = oauthUser(['write']);
|
||||
|
||||
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
|
||||
});
|
||||
|
||||
describe('session-only routes', () => {
|
||||
const sessionUser = {
|
||||
user: { id: 'user_1' },
|
||||
workspace: { id: 'ws_1' },
|
||||
authType: JwtType.ACCESS,
|
||||
};
|
||||
|
||||
it('allows a signed-in session', () => {
|
||||
const { guard } = createGuard(undefined, true);
|
||||
|
||||
expect(guard.handleRequest(null, sessionUser, null, createCtx())).toBe(
|
||||
sessionUser,
|
||||
);
|
||||
});
|
||||
|
||||
it('forbids an api key', () => {
|
||||
const { guard } = createGuard(undefined, true);
|
||||
const apiKeyUser = {
|
||||
user: { id: 'user_1' },
|
||||
workspace: { id: 'ws_1' },
|
||||
authType: JwtType.API_KEY,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
guard.handleRequest(null, apiKeyUser, null, createCtx()),
|
||||
).toThrow('This action requires an interactive user session');
|
||||
});
|
||||
|
||||
it('forbids an oauth token even when it carries write scope', () => {
|
||||
const { guard } = createGuard('write', true);
|
||||
const user = { ...oauthUser(['write']), authType: JwtType.OAUTH_ACCESS };
|
||||
|
||||
expect(() => guard.handleRequest(null, user, null, createCtx())).toThrow(
|
||||
'This action requires an interactive user session',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves api keys working on routes without the marker', () => {
|
||||
const { guard } = createGuard(undefined, undefined);
|
||||
const apiKeyUser = {
|
||||
user: { id: 'user_1' },
|
||||
workspace: { id: 'ws_1' },
|
||||
authType: JwtType.API_KEY,
|
||||
};
|
||||
|
||||
expect(guard.handleRequest(null, apiKeyUser, null, createCtx())).toBe(
|
||||
apiKeyUser,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets handler metadata override class metadata', () => {
|
||||
const metadataByTarget = new Map<unknown, string>([
|
||||
[handlerSentinel, 'write'],
|
||||
[classSentinel, 'read'],
|
||||
]);
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string, targets: unknown[]) => {
|
||||
if (key === REQUIRE_SESSION_AUTH_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (metadataByTarget.has(target)) {
|
||||
return metadataByTarget.get(target);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as any;
|
||||
const environmentService = { isCloud: jest.fn().mockReturnValue(false) } as any;
|
||||
const guard = new JwtAuthGuard(reflector, environmentService);
|
||||
|
||||
expect(() =>
|
||||
guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
|
||||
).toThrow('insufficient_scope');
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,16 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import {
|
||||
OAUTH_SCOPE_KEY,
|
||||
OAuthRouteScope,
|
||||
} from '../decorators/oauth-scope.decorator';
|
||||
import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
|
||||
import { JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { addDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
private logger = new Logger('JwtAuthGuard');
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private environmentService: EnvironmentService,
|
||||
@@ -46,39 +36,6 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
throw err || new UnauthorizedException();
|
||||
}
|
||||
|
||||
const requiresSession = this.reflector.getAllAndOverride<boolean>(
|
||||
REQUIRE_SESSION_AUTH_KEY,
|
||||
[ctx.getHandler(), ctx.getClass()],
|
||||
);
|
||||
if (requiresSession && user.authType !== JwtType.ACCESS) {
|
||||
this.logger.debug(
|
||||
`session-only endpoint ${ctx.getClass()?.name}.${ctx.getHandler()?.name} refused authType ${user.authType}`,
|
||||
);
|
||||
throw new ForbiddenException(
|
||||
'This action requires an interactive user session',
|
||||
);
|
||||
}
|
||||
|
||||
if (user.oauth) {
|
||||
const required = this.reflector.getAllAndOverride<
|
||||
OAuthRouteScope | undefined
|
||||
>(OAUTH_SCOPE_KEY, [ctx.getHandler(), ctx.getClass()]);
|
||||
if (!required) {
|
||||
this.logger.warn(
|
||||
`oauth scope check: no @OAuthScope metadata on ${ctx.getClass()?.name}.${ctx.getHandler()?.name}`,
|
||||
);
|
||||
throw new ForbiddenException('OAuth tokens cannot access this endpoint');
|
||||
}
|
||||
const scopes: string[] = user.oauth.scopes ?? [];
|
||||
const satisfied =
|
||||
required === 'read'
|
||||
? scopes.includes('read') || scopes.includes('write')
|
||||
: scopes.includes('write');
|
||||
if (!satisfied) {
|
||||
throw new ForbiddenException('insufficient_scope');
|
||||
}
|
||||
}
|
||||
|
||||
this.setJoinedWorkspacesCookie(user, ctx);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
resolveFrameHeader,
|
||||
resolveFrameHeadersForPath,
|
||||
SecurityHeader,
|
||||
} from './security-headers';
|
||||
|
||||
describe('resolveFrameHeader', () => {
|
||||
it('denies framing with X-Frame-Options when embedding is off', () => {
|
||||
expect(resolveFrameHeader(false, [])).toEqual({
|
||||
name: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when embedding is on but no origins are configured', () => {
|
||||
expect(resolveFrameHeader(true, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('emits a frame-ancestors CSP for the allowed origins', () => {
|
||||
expect(resolveFrameHeader(true, ['https://a.example', 'https://b.example']))
|
||||
.toEqual({
|
||||
name: 'Content-Security-Policy',
|
||||
value: "frame-ancestors 'self' https://a.example https://b.example",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFrameHeadersForPath', () => {
|
||||
const configured: SecurityHeader = {
|
||||
name: 'Content-Security-Policy',
|
||||
value: "frame-ancestors 'self' https://a.example",
|
||||
};
|
||||
|
||||
it.each(['/oauth/consent', '/oauth/consent/nested'])(
|
||||
'force-denies %s regardless of configured header',
|
||||
(path) => {
|
||||
expect(resolveFrameHeadersForPath(path, configured)).toEqual([
|
||||
{ name: 'X-Frame-Options', value: 'DENY' },
|
||||
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('force-denies consent even when the global header is absent', () => {
|
||||
expect(resolveFrameHeadersForPath('/oauth/consent', null)).toEqual([
|
||||
{ name: 'X-Frame-Options', value: 'DENY' },
|
||||
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not match an unrelated path that merely contains the prefix', () => {
|
||||
expect(
|
||||
resolveFrameHeadersForPath('/oauth/consenting-adults', configured),
|
||||
).toEqual([configured]);
|
||||
});
|
||||
|
||||
it('passes the configured header through for other paths', () => {
|
||||
expect(resolveFrameHeadersForPath('/home', configured)).toEqual([
|
||||
configured,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing for other paths when no header is configured', () => {
|
||||
expect(resolveFrameHeadersForPath('/home', null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,19 +17,3 @@ export function resolveFrameHeader(
|
||||
value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Deny OAuth consent in iframe
|
||||
export const OAUTH_CONSENT_PATH = '/oauth/consent';
|
||||
|
||||
export function resolveFrameHeadersForPath(
|
||||
path: string,
|
||||
configuredHeader: SecurityHeader | null,
|
||||
): SecurityHeader[] {
|
||||
if (path === OAUTH_CONSENT_PATH || path.startsWith(`${OAUTH_CONSENT_PATH}/`)) {
|
||||
return [
|
||||
{ name: 'X-Frame-Options', value: 'DENY' },
|
||||
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
|
||||
];
|
||||
}
|
||||
return configuredHeader ? [configuredHeader] : [];
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export type RedisConfig = {
|
||||
host: string;
|
||||
port: number;
|
||||
db: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
family?: number;
|
||||
tls?: { rejectUnauthorized?: boolean };
|
||||
@@ -37,15 +36,7 @@ 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,
|
||||
username,
|
||||
password,
|
||||
pathname,
|
||||
protocol,
|
||||
searchParams,
|
||||
} = url;
|
||||
const { hostname, port, password, pathname, protocol, searchParams } = url;
|
||||
const portInt = port ? parseInt(port, 10) : 6379;
|
||||
|
||||
let db: number = 0;
|
||||
@@ -71,15 +62,7 @@ export function parseRedisUrl(redisUrl: string): RedisConfig {
|
||||
: {}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
host: hostname,
|
||||
port: portInt,
|
||||
username: username ? decodeURIComponent(username) : undefined,
|
||||
password: password ? decodeURIComponent(password) : undefined,
|
||||
db,
|
||||
family,
|
||||
tls,
|
||||
};
|
||||
return { host: hostname, port: portInt, password: password || undefined, db, family, tls };
|
||||
}
|
||||
|
||||
export function createRetryStrategy() {
|
||||
|
||||
@@ -24,7 +24,6 @@ import * as bytes from 'bytes';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
|
||||
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { StorageService } from '../../integrations/storage/storage.service';
|
||||
import {
|
||||
@@ -176,7 +175,6 @@ export class AttachmentController {
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('/files/:fileId/:fileName')
|
||||
@OAuthScope('read')
|
||||
async getFile(
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
@@ -429,7 +427,6 @@ export class AttachmentController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages/attachments')
|
||||
@OAuthScope('read')
|
||||
async getPageAttachments(
|
||||
@Body() dto: PageIdDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import {
|
||||
ALL_NAMED_THROTTLERS_SKIPPED,
|
||||
AI_CHAT_THROTTLER,
|
||||
AUTH_THROTTLER,
|
||||
} from '../../integrations/throttle/throttler-names';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
|
||||
@SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [AUTH_THROTTLER]: false })
|
||||
@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
|
||||
@@ -7,7 +7,6 @@ export enum JwtType {
|
||||
API_KEY = 'api_key',
|
||||
PDF_RENDER = 'pdf_render',
|
||||
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
|
||||
OAUTH_ACCESS = 'oauth_access',
|
||||
}
|
||||
export type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -60,14 +59,3 @@ export type JwtPdfExportDownloadPayload = {
|
||||
workspaceId: string;
|
||||
type: 'pdf_export_download';
|
||||
};
|
||||
|
||||
export type JwtOAuthPayload = {
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
grantId: string;
|
||||
scope: string;
|
||||
aud: string;
|
||||
iss: string;
|
||||
jti: string;
|
||||
type: JwtType.OAUTH_ACCESS;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
@@ -41,13 +40,9 @@ import {
|
||||
IAuditService,
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventName } from '../../../common/events/event.contants';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private signupService: SignupService,
|
||||
private tokenService: TokenService,
|
||||
@@ -58,7 +53,6 @@ export class AuthService {
|
||||
private mailService: MailService,
|
||||
private domainService: DomainService,
|
||||
private environmentService: EnvironmentService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
@@ -219,13 +213,6 @@ 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(
|
||||
@@ -274,19 +261,6 @@ export class AuthService {
|
||||
|
||||
await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
|
||||
|
||||
// A failed revocation must not block the reset itself; log loudly instead.
|
||||
try {
|
||||
await this.eventEmitter.emitAsync(EventName.USER_PASSWORD_RESET, {
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`failed to revoke oauth grants for user ${user.id} after password reset`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
this.auditService.setActorId(user.id);
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_RESET,
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import {
|
||||
JwtApiKeyPayload,
|
||||
JwtOAuthPayload,
|
||||
JwtPayload,
|
||||
JwtType,
|
||||
} from '../dto/jwt-payload';
|
||||
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
@@ -38,10 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
req: any,
|
||||
payload: JwtPayload | JwtApiKeyPayload | JwtOAuthPayload,
|
||||
) {
|
||||
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
|
||||
if (!payload.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
@@ -50,21 +42,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
throw new UnauthorizedException('Workspace does not match');
|
||||
}
|
||||
|
||||
// authType lets guards tell an interactive session from a programmatic credential.
|
||||
if (payload.type === JwtType.API_KEY) {
|
||||
const authResult = await this.validateApiKey(
|
||||
req,
|
||||
payload as JwtApiKeyPayload,
|
||||
);
|
||||
return { ...authResult, authType: JwtType.API_KEY };
|
||||
}
|
||||
|
||||
if (payload.type === JwtType.OAUTH_ACCESS) {
|
||||
const authResult = await this.validateOAuthToken(
|
||||
req,
|
||||
payload as JwtOAuthPayload,
|
||||
);
|
||||
return { ...authResult, authType: JwtType.OAUTH_ACCESS };
|
||||
return this.validateApiKey(req, payload as JwtApiKeyPayload);
|
||||
}
|
||||
|
||||
if (payload.type !== JwtType.ACCESS) {
|
||||
@@ -92,7 +71,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
|
||||
}
|
||||
|
||||
return { user, workspace, authType: JwtType.ACCESS };
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
|
||||
@@ -120,36 +99,4 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
|
||||
private async validateOAuthToken(req: any, payload: JwtOAuthPayload) {
|
||||
let OAuthStrategyModule: any;
|
||||
let isOAuthModuleReady = false;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
OAuthStrategyModule = require('./../../../ee/oauth/services/oauth-strategy.service');
|
||||
isOAuthModuleReady = true;
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'OAuth module requested but enterprise module not bundled in this build',
|
||||
);
|
||||
isOAuthModuleReady = false;
|
||||
}
|
||||
|
||||
if (isOAuthModuleReady) {
|
||||
const OAuthStrategyService = this.moduleRef.get(
|
||||
OAuthStrategyModule.OAuthStrategyService,
|
||||
{
|
||||
strict: false,
|
||||
},
|
||||
);
|
||||
|
||||
return OAuthStrategyService.validateOAuthToken(payload, {
|
||||
workspaceId: req.raw.workspaceId,
|
||||
host: req.raw.headers?.host ?? req.headers?.host,
|
||||
});
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Enterprise OAuth module missing');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
@@ -49,7 +48,6 @@ export class CommentController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
@OAuthScope('write')
|
||||
async create(
|
||||
@Body() createCommentDto: CreateCommentDto,
|
||||
@AuthUser() user: User,
|
||||
@@ -86,7 +84,6 @@ export class CommentController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
@OAuthScope('read')
|
||||
async findPageComments(
|
||||
@Body() input: PageIdDto,
|
||||
@Body()
|
||||
@@ -128,7 +125,6 @@ export class CommentController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
@OAuthScope('write')
|
||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||
includeCreator: true,
|
||||
|
||||
@@ -10,9 +10,6 @@ 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 =
|
||||
@@ -43,9 +40,6 @@ 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[] = [
|
||||
|
||||
@@ -26,7 +26,6 @@ import { PageHistoryService } from './services/page-history.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { SidebarPageDto } from './dto/sidebar-page.dto';
|
||||
@@ -70,7 +69,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/info')
|
||||
@OAuthScope('read')
|
||||
async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(dto.pageId, {
|
||||
includeSpace: true,
|
||||
@@ -201,7 +199,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
@OAuthScope('write')
|
||||
async create(
|
||||
@Body() createPageDto: CreatePageDto,
|
||||
@AuthUser() user: User,
|
||||
@@ -272,7 +269,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
@OAuthScope('write')
|
||||
async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(updatePageDto.pageId);
|
||||
|
||||
@@ -417,7 +413,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('recent')
|
||||
@OAuthScope('read')
|
||||
async getRecentPages(
|
||||
@Body() recentPageDto: RecentPageDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@@ -532,7 +527,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/sidebar-pages')
|
||||
@OAuthScope('read')
|
||||
async getSidebarPages(
|
||||
@Body() dto: SidebarPageDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@@ -575,7 +569,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('move-to-space')
|
||||
@OAuthScope('write')
|
||||
async movePageToSpace(
|
||||
@Body() dto: MovePageToSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
@@ -629,7 +622,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('duplicate')
|
||||
@OAuthScope('write')
|
||||
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
|
||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||
if (!copiedPage) {
|
||||
@@ -714,7 +706,6 @@ export class PageController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('move')
|
||||
@OAuthScope('write')
|
||||
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
|
||||
const movedPage = await this.pageRepo.findById(dto.pageId);
|
||||
if (!movedPage) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from './dto/search.dto';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import {
|
||||
@@ -43,7 +42,6 @@ export class SearchController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
@OAuthScope('read')
|
||||
async pageSearch(
|
||||
@Body() searchDto: SearchDTO,
|
||||
@AuthUser() user: User,
|
||||
@@ -77,7 +75,6 @@ export class SearchController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('suggest')
|
||||
@OAuthScope('read')
|
||||
async searchSuggestions(
|
||||
@Body() dto: SearchSuggestionDTO,
|
||||
@AuthUser() user: User,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { SpaceService } from './services/space.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
|
||||
import { SpaceIdDto } from './dto/space-id.dto';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { SpaceMemberService } from './services/space-member.service';
|
||||
@@ -49,7 +48,6 @@ export class SpaceController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/')
|
||||
@OAuthScope('read')
|
||||
async getWorkspaceSpaces(
|
||||
@Body()
|
||||
pagination: PaginationOptions,
|
||||
@@ -94,7 +92,6 @@ export class SpaceController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('info')
|
||||
@OAuthScope('read')
|
||||
async getSpaceInfo(
|
||||
@Body() spaceIdDto: SpaceIdDto,
|
||||
@AuthUser() user: User,
|
||||
@@ -132,7 +129,6 @@ export class SpaceController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('create')
|
||||
@OAuthScope('write')
|
||||
createSpace(
|
||||
@Body() createSpaceDto: CreateSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
@@ -149,7 +145,6 @@ export class SpaceController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
@OAuthScope('write')
|
||||
async updateSpace(
|
||||
@Body() updateSpaceDto: UpdateSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { UserService } from './user.service';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
@@ -25,7 +24,6 @@ export class UserController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('me')
|
||||
@OAuthScope('read')
|
||||
async getUserInfo(
|
||||
@AuthUser() authUser: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
RevokeInviteDto,
|
||||
} from '../dto/invitation.dto';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { OAuthScope } from '../../../common/decorators/oauth-scope.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
|
||||
import {
|
||||
@@ -59,7 +58,6 @@ export class WorkspaceController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/info')
|
||||
@OAuthScope('read')
|
||||
async getWorkspace(@AuthWorkspace() workspace: Workspace) {
|
||||
return this.workspaceService.getWorkspaceInfo(workspace.id);
|
||||
}
|
||||
@@ -115,7 +113,6 @@ export class WorkspaceController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('members')
|
||||
@OAuthScope('read')
|
||||
async getWorkspaceMembers(
|
||||
@Body()
|
||||
pagination: PaginationOptions,
|
||||
|
||||
@@ -76,8 +76,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
aiChatWorkspaceKnowledgeOnly: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enforceMcpOauth: boolean;
|
||||
}
|
||||
|
||||
@@ -336,8 +336,7 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.enforceMcpOauth !== 'undefined'
|
||||
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined'
|
||||
) {
|
||||
const ws = await this.db
|
||||
.selectFrom('workspaces')
|
||||
@@ -392,18 +391,6 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.enforceMcpOauth !== 'undefined') {
|
||||
if (
|
||||
!this.licenseCheckService.hasFeature(
|
||||
ws.licenseKey,
|
||||
Feature.MCP_CONTROLS,
|
||||
ws.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
@@ -574,20 +561,6 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
'enforceMcpOauth',
|
||||
updateWorkspaceDto.enforceMcpOauth,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
|
||||
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
|
||||
@@ -627,7 +600,6 @@ export class WorkspaceService {
|
||||
delete updateWorkspaceDto.defaultPageEditMode;
|
||||
delete updateWorkspaceDto.aiChatReadOnly;
|
||||
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
|
||||
delete updateWorkspaceDto.enforceMcpOauth;
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(
|
||||
updateWorkspaceDto,
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('oauth_clients')
|
||||
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
|
||||
.addColumn('name', 'text', (col) => col.notNull())
|
||||
.addColumn('redirect_uris', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('client_uri', 'text')
|
||||
.addColumn('logo_uri', 'text')
|
||||
.addColumn('grant_types', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('scopes', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('token_endpoint_auth_method', 'text', (col) => col.notNull().defaultTo('none'))
|
||||
.addColumn('secret_hash', 'text')
|
||||
.addColumn('is_dynamic', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
|
||||
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
|
||||
.addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.execute();
|
||||
await db.schema.createIndex('oauth_clients_workspace_id_idx').on('oauth_clients').column('workspace_id').execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('oauth_authorization_codes')
|
||||
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
|
||||
.addColumn('code_hash', 'text', (col) => col.notNull().unique())
|
||||
.addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
|
||||
.addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
|
||||
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
|
||||
.addColumn('scopes', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('redirect_uri', 'text', (col) => col.notNull())
|
||||
.addColumn('code_challenge', 'text')
|
||||
.addColumn('code_challenge_method', 'text')
|
||||
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
|
||||
.addColumn('consumed_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
|
||||
.execute();
|
||||
|
||||
await db.schema.createIndex('oauth_authorization_codes_expires_at_idx').on('oauth_authorization_codes').column('expires_at').execute();
|
||||
await db.schema.createIndex('oauth_authorization_codes_consumed_at_idx').on('oauth_authorization_codes').column('consumed_at').execute();
|
||||
await db.schema.createIndex('oauth_authorization_codes_client_id_idx').on('oauth_authorization_codes').column('client_id').execute();
|
||||
await db.schema.createIndex('oauth_authorization_codes_user_id_idx').on('oauth_authorization_codes').column('user_id').execute();
|
||||
await db.schema.createIndex('oauth_authorization_codes_workspace_id_idx').on('oauth_authorization_codes').column('workspace_id').execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('oauth_grants')
|
||||
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
|
||||
.addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
|
||||
.addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
|
||||
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
|
||||
.addColumn('scopes', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
|
||||
.addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
|
||||
.addColumn('last_used_at', 'timestamptz')
|
||||
.addColumn('revoked_at', 'timestamptz')
|
||||
.addUniqueConstraint('oauth_grants_user_client_unique', ['user_id', 'client_id'])
|
||||
.execute();
|
||||
|
||||
// The user_id/client_id unique constraint cannot serve client-side FK lookups.
|
||||
await db.schema.createIndex('oauth_grants_client_id_idx').on('oauth_grants').column('client_id').execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('oauth_tokens')
|
||||
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
|
||||
.addColumn('grant_id', 'uuid', (col) => col.notNull().references('oauth_grants.id').onDelete('cascade'))
|
||||
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
|
||||
.addColumn('access_token_jti', 'text', (col) => col.notNull())
|
||||
.addColumn('refresh_token_hash', 'text', (col) => col.unique())
|
||||
.addColumn('scopes', 'jsonb', (col) => col.notNull())
|
||||
.addColumn('access_expires_at', 'timestamptz', (col) => col.notNull())
|
||||
.addColumn('refresh_expires_at', 'timestamptz')
|
||||
.addColumn('revoked_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
|
||||
.execute();
|
||||
await db.schema.createIndex('oauth_tokens_grant_id_idx').on('oauth_tokens').column('grant_id').execute();
|
||||
await db.schema.createIndex('oauth_tokens_access_token_jti_idx').on('oauth_tokens').columns(['workspace_id', 'access_token_jti']).execute();
|
||||
// One index per branch of the cleanup sweep's OR so it can bitmap-or them.
|
||||
await db.schema.createIndex('oauth_tokens_access_expires_at_idx').on('oauth_tokens').column('access_expires_at').execute();
|
||||
await db.schema.createIndex('oauth_tokens_refresh_expires_at_idx').on('oauth_tokens').column('refresh_expires_at').execute();
|
||||
await db.schema.createIndex('oauth_tokens_revoked_at_idx').on('oauth_tokens').column('revoked_at').execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('oauth_tokens').execute();
|
||||
await db.schema.dropTable('oauth_grants').execute();
|
||||
await db.schema.dropTable('oauth_authorization_codes').execute();
|
||||
await db.schema.dropTable('oauth_clients').execute();
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
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<any>): Promise<void> {
|
||||
await db.schema.alterTable('audit').dropColumn('user_agent').execute();
|
||||
await db.schema.dropTable('siem_destinations').ifExists().execute();
|
||||
}
|
||||
@@ -60,10 +60,6 @@ export class GroupUserRepo {
|
||||
sql`f_unaccent(users.name)`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
).or(
|
||||
sql`users.email`,
|
||||
'ilike',
|
||||
sql`f_unaccent(${'%' + pagination.query + '%'})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
-87
@@ -74,7 +74,6 @@ export interface Audit {
|
||||
resourceId: string | null;
|
||||
resourceType: string;
|
||||
spaceId: string | null;
|
||||
userAgent: string | null;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@@ -362,30 +361,6 @@ export interface SpaceMembers {
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export interface SiemDestinations {
|
||||
config: Json;
|
||||
consecutiveFailures: Generated<number>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
cursorCreatedAt: Generated<Timestamp>;
|
||||
cursorId: Generated<string>;
|
||||
cursorSnapshot: string | null;
|
||||
enabled: Generated<boolean>;
|
||||
failingSince: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
lastDeliveredAt: Timestamp | null;
|
||||
lastError: string | null;
|
||||
lastErrorAt: Timestamp | null;
|
||||
name: string;
|
||||
nextAttemptAt: Timestamp | null;
|
||||
secrets: string;
|
||||
status: Generated<string>;
|
||||
type: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
version: Generated<number>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface Spaces {
|
||||
createdAt: Generated<Timestamp>;
|
||||
creatorId: string | null;
|
||||
@@ -661,63 +636,6 @@ export interface UserSessions {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface OauthAuthorizationCodes {
|
||||
clientId: string;
|
||||
codeChallenge: string | null;
|
||||
codeChallengeMethod: string | null;
|
||||
codeHash: string;
|
||||
consumedAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
expiresAt: Timestamp;
|
||||
id: Generated<string>;
|
||||
redirectUri: string;
|
||||
scopes: Json;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface OauthClients {
|
||||
clientUri: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
grantTypes: Json;
|
||||
id: Generated<string>;
|
||||
isDynamic: Generated<boolean>;
|
||||
logoUri: string | null;
|
||||
name: string;
|
||||
redirectUris: Json;
|
||||
scopes: Json;
|
||||
secretHash: string | null;
|
||||
tokenEndpointAuthMethod: Generated<string>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface OauthGrants {
|
||||
clientId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
id: Generated<string>;
|
||||
lastUsedAt: Timestamp | null;
|
||||
revokedAt: Timestamp | null;
|
||||
scopes: Json;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface OauthTokens {
|
||||
accessExpiresAt: Timestamp;
|
||||
accessTokenJti: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
grantId: string;
|
||||
id: Generated<string>;
|
||||
refreshExpiresAt: Timestamp | null;
|
||||
refreshTokenHash: string | null;
|
||||
revokedAt: Timestamp | null;
|
||||
scopes: Json;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
aiChats: AiChats;
|
||||
aiChatMessages: AiChatMessages;
|
||||
@@ -749,7 +667,6 @@ export interface DB {
|
||||
pages: Pages;
|
||||
scimTokens: ScimTokens;
|
||||
shares: Shares;
|
||||
siemDestinations: SiemDestinations;
|
||||
spaceMembers: SpaceMembers;
|
||||
spaces: Spaces;
|
||||
templates: Templates;
|
||||
@@ -760,8 +677,4 @@ export interface DB {
|
||||
watchers: Watchers;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
workspaces: Workspaces;
|
||||
oauthAuthorizationCodes: OauthAuthorizationCodes;
|
||||
oauthClients: OauthClients;
|
||||
oauthGrants: OauthGrants;
|
||||
oauthTokens: OauthTokens;
|
||||
}
|
||||
|
||||
@@ -37,14 +37,9 @@ import {
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
ScimTokens,
|
||||
SiemDestinations,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
Templates,
|
||||
OauthClients,
|
||||
OauthAuthorizationCodes,
|
||||
OauthGrants,
|
||||
OauthTokens,
|
||||
} from './db';
|
||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
|
||||
@@ -189,13 +184,6 @@ export type ScimToken = Selectable<ScimTokens>;
|
||||
export type InsertableScimToken = Insertable<ScimTokens>;
|
||||
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
|
||||
|
||||
// OAuth
|
||||
export type OAuthClient = Selectable<OauthClients>;
|
||||
export type InsertableOAuthClient = Insertable<OauthClients>;
|
||||
export type OAuthGrant = Selectable<OauthGrants>;
|
||||
export type OAuthAuthorizationCode = Selectable<OauthAuthorizationCodes>;
|
||||
export type OAuthToken = Selectable<OauthTokens>;
|
||||
|
||||
// Page Embedding
|
||||
export type PageEmbedding = Selectable<PageEmbeddings>;
|
||||
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
|
||||
@@ -268,8 +256,3 @@ export type UpdatableBaseRow = Updateable<Omit<BaseRows, 'id'>>;
|
||||
export type BaseView = Selectable<BaseViews>;
|
||||
export type InsertableBaseView = Insertable<BaseViews>;
|
||||
export type UpdatableBaseView = Updateable<Omit<BaseViews, 'id'>>;
|
||||
|
||||
// SIEM destinations
|
||||
export type SiemDestination = Selectable<SiemDestinations>;
|
||||
export type InsertableSiemDestination = Insertable<SiemDestinations>;
|
||||
export type UpdatableSiemDestination = Updateable<Omit<SiemDestinations, 'id'>>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 32f1664543...4c837b7635
@@ -385,8 +385,4 @@ export class EnvironmentService {
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
getAllowedPrivateNetworks(): string {
|
||||
return this.configService.get<string>('ALLOWED_PRIVATE_NETWORKS', 'none');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,15 +97,6 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
}
|
||||
}
|
||||
|
||||
function isBareLink($el: Cheerio<any>): boolean {
|
||||
const href = $el.attr("href")?.trim();
|
||||
const text = $el.text().trim();
|
||||
|
||||
if(!text || !href) return false
|
||||
|
||||
return text === href;
|
||||
}
|
||||
|
||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
normalizeTableColumnWidths($, $root);
|
||||
|
||||
@@ -113,9 +104,7 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||
const $el = $(el);
|
||||
const url = $el.attr('href')!;
|
||||
const { provider } = getEmbedUrlAndProvider(url);
|
||||
if (provider === 'iframe' || !isBareLink($el)) {
|
||||
return;
|
||||
}
|
||||
if (provider === 'iframe') return;
|
||||
|
||||
const embed = `<div data-type=\"embed\" data-src=\"${url}\" data-provider=\"${provider}\" data-align=\"center\" data-width=\"640\" data-height=\"480\"></div>`;
|
||||
$el.replaceWith(embed);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
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<void>;
|
||||
};
|
||||
|
||||
export type IOutboundAgentFactory = {
|
||||
lease(url: string, tls?: OutboundTlsOptions): Promise<AgentLease>;
|
||||
};
|
||||
|
||||
/** 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<AgentLease> {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
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<ResolvedAddress[]>;
|
||||
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<PinnedAddress> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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 {}
|
||||
@@ -10,7 +10,6 @@ export enum QueueName {
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
AUDIT_QUEUE = '{audit-queue}',
|
||||
BASE_QUEUE = '{base-queue}',
|
||||
SIEM_QUEUE = '{siem-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -84,9 +83,6 @@ 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',
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ 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,
|
||||
@@ -94,14 +93,6 @@ 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: {
|
||||
|
||||
@@ -16,7 +16,6 @@ export class RedisConfigService implements RedisOptionsFactory {
|
||||
config: {
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
username: redisConfig.username,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
|
||||
@@ -4,14 +4,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { EnvironmentModule } from '../environment/environment.module';
|
||||
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
|
||||
import {
|
||||
AUTH_THROTTLER,
|
||||
AI_CHAT_THROTTLER,
|
||||
OAUTH_REGISTER_THROTTLER,
|
||||
OAUTH_TOKEN_THROTTLER,
|
||||
OAUTH_AUTHORIZE_THROTTLER,
|
||||
SIEM_TEST_THROTTLER,
|
||||
} from './throttler-names';
|
||||
import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Module({
|
||||
@@ -25,17 +18,12 @@ import Redis from 'ioredis';
|
||||
throttlers: [
|
||||
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
|
||||
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
|
||||
{ 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(
|
||||
new Redis({
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
username: redisConfig.username,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
|
||||
@@ -1,16 +1,2 @@
|
||||
export const AUTH_THROTTLER = 'auth';
|
||||
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<string, boolean> = {
|
||||
[AUTH_THROTTLER]: true,
|
||||
[AI_CHAT_THROTTLER]: true,
|
||||
[OAUTH_REGISTER_THROTTLER]: true,
|
||||
[OAUTH_TOKEN_THROTTLER]: true,
|
||||
[OAUTH_AUTHORIZE_THROTTLER]: true,
|
||||
[SIEM_TEST_THROTTLER]: true,
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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 (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
Your SIEM destination <strong>{destinationName}</strong> (
|
||||
{destinationType}) has been failing since {failingSince} and was
|
||||
disabled after 24 hours of failed deliveries.
|
||||
</Text>
|
||||
<Text style={paragraph}>Last error: {lastError}</Text>
|
||||
<Text style={paragraph}>
|
||||
Your events are kept and delivery resumes from where it stopped when
|
||||
you re-enable it.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={settingsLink}>View destination</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiemDestinationDisabledEmail;
|
||||
@@ -1,41 +0,0 @@
|
||||
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 (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
Docmost cannot deliver audit events to your SIEM destination{' '}
|
||||
<strong>{destinationName}</strong> ({destinationType}).
|
||||
</Text>
|
||||
<Text style={paragraph}>Last error: {lastError}</Text>
|
||||
<Text style={paragraph}>Failing since {failingSince}.</Text>
|
||||
<Text style={paragraph}>
|
||||
Docmost keeps retrying every 30 minutes. If the destination is still
|
||||
failing 24 hours after it started, it is disabled automatically.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={settingsLink}>View destination</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiemDestinationFailingEmail;
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
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 (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>
|
||||
Your SIEM destination <strong>{destinationName}</strong> (
|
||||
{destinationType}) is delivering audit events again.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
Events buffered during the outage were delivered from where the stream
|
||||
stopped.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={settingsLink}>View destination</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiemDestinationRecoveredEmail;
|
||||
+18
-30
@@ -13,10 +13,7 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyIp from 'fastify-ip';
|
||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
||||
import {
|
||||
resolveFrameHeader,
|
||||
resolveFrameHeadersForPath,
|
||||
} from './common/helpers';
|
||||
import { resolveFrameHeader } from './common/helpers';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -42,14 +39,7 @@ async function bootstrap() {
|
||||
app.useLogger(app.get(PinoLogger));
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [
|
||||
'robots.txt',
|
||||
'share/:shareId/p/:pageSlug',
|
||||
'mcp',
|
||||
'.well-known/oauth-authorization-server',
|
||||
'.well-known/oauth-protected-resource',
|
||||
'.well-known/oauth-protected-resource/mcp',
|
||||
],
|
||||
exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
|
||||
});
|
||||
|
||||
const reflector = app.get(Reflector);
|
||||
@@ -67,24 +57,22 @@ async function bootstrap() {
|
||||
environmentService.isIframeEmbedAllowed(),
|
||||
environmentService.getIframeAllowedOrigins(),
|
||||
);
|
||||
// Skipped routes:
|
||||
// /api/files/ - attachment controller sets its own CSP we'd overwrite
|
||||
// /share/ - public share pages are safe to embed
|
||||
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addHook('onSend', (req, reply, payload, done) => {
|
||||
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
|
||||
return done(null, payload);
|
||||
}
|
||||
const path = req.url.split('?')[0];
|
||||
// Force-denies the oauth consent screen even when the global frame header is absent.
|
||||
for (const header of resolveFrameHeadersForPath(path, frameHeader)) {
|
||||
reply.header(header.name, header.value);
|
||||
}
|
||||
done(null, payload);
|
||||
});
|
||||
if (frameHeader) {
|
||||
// Skipped routes:
|
||||
// /api/files/ - attachment controller sets its own CSP we'd overwrite
|
||||
// /share/ 0 public share pages are safe to embed
|
||||
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
|
||||
app
|
||||
.getHttpAdapter()
|
||||
.getInstance()
|
||||
.addHook('onSend', (req, reply, payload, done) => {
|
||||
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
|
||||
return done(null, payload);
|
||||
}
|
||||
reply.header(frameHeader.name, frameHeader.value);
|
||||
done(null, payload);
|
||||
});
|
||||
}
|
||||
|
||||
app
|
||||
.getHttpAdapter()
|
||||
|
||||
+29
-29
@@ -31,34 +31,34 @@
|
||||
"@joplin/turndown": "4.0.82",
|
||||
"@joplin/turndown-plugin-gfm": "1.0.64",
|
||||
"@sindresorhus/slugify": "3.0.0",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/extension-audio": "3.31.3",
|
||||
"@tiptap/extension-code-block": "3.31.3",
|
||||
"@tiptap/extension-collaboration": "3.31.3",
|
||||
"@tiptap/extension-collaboration-caret": "3.31.3",
|
||||
"@tiptap/extension-color": "3.31.3",
|
||||
"@tiptap/extension-document": "3.31.3",
|
||||
"@tiptap/extension-heading": "3.31.3",
|
||||
"@tiptap/extension-highlight": "3.31.3",
|
||||
"@tiptap/extension-history": "3.31.3",
|
||||
"@tiptap/extension-image": "3.31.3",
|
||||
"@tiptap/extension-link": "3.31.3",
|
||||
"@tiptap/extension-list": "3.31.3",
|
||||
"@tiptap/extension-placeholder": "3.31.3",
|
||||
"@tiptap/extension-subscript": "3.31.3",
|
||||
"@tiptap/extension-superscript": "3.31.3",
|
||||
"@tiptap/extension-table": "3.31.3",
|
||||
"@tiptap/extension-text": "3.31.3",
|
||||
"@tiptap/extension-text-align": "3.31.3",
|
||||
"@tiptap/extension-text-style": "3.31.3",
|
||||
"@tiptap/extension-typography": "3.31.3",
|
||||
"@tiptap/extension-unique-id": "3.31.3",
|
||||
"@tiptap/extension-youtube": "3.31.3",
|
||||
"@tiptap/html": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@tiptap/react": "3.31.3",
|
||||
"@tiptap/starter-kit": "3.31.3",
|
||||
"@tiptap/suggestion": "3.31.3",
|
||||
"@tiptap/core": "3.29.2",
|
||||
"@tiptap/extension-audio": "3.29.2",
|
||||
"@tiptap/extension-code-block": "3.29.2",
|
||||
"@tiptap/extension-collaboration": "3.29.2",
|
||||
"@tiptap/extension-collaboration-caret": "3.29.2",
|
||||
"@tiptap/extension-color": "3.29.2",
|
||||
"@tiptap/extension-document": "3.29.2",
|
||||
"@tiptap/extension-heading": "3.29.2",
|
||||
"@tiptap/extension-highlight": "3.29.2",
|
||||
"@tiptap/extension-history": "3.29.2",
|
||||
"@tiptap/extension-image": "3.29.2",
|
||||
"@tiptap/extension-link": "3.29.2",
|
||||
"@tiptap/extension-list": "3.29.2",
|
||||
"@tiptap/extension-placeholder": "3.29.2",
|
||||
"@tiptap/extension-subscript": "3.29.2",
|
||||
"@tiptap/extension-superscript": "3.29.2",
|
||||
"@tiptap/extension-table": "3.29.2",
|
||||
"@tiptap/extension-text": "3.29.2",
|
||||
"@tiptap/extension-text-align": "3.29.2",
|
||||
"@tiptap/extension-text-style": "3.29.2",
|
||||
"@tiptap/extension-typography": "3.29.2",
|
||||
"@tiptap/extension-unique-id": "3.29.2",
|
||||
"@tiptap/extension-youtube": "3.29.2",
|
||||
"@tiptap/html": "3.29.2",
|
||||
"@tiptap/pm": "3.29.2",
|
||||
"@tiptap/react": "3.29.2",
|
||||
"@tiptap/starter-kit": "3.29.2",
|
||||
"@tiptap/suggestion": "3.29.2",
|
||||
"@tiptap/y-tiptap": "3.0.7",
|
||||
"bytes": "3.1.2",
|
||||
"cross-env": "10.1.0",
|
||||
@@ -95,5 +95,5 @@
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@11.25.0"
|
||||
"packageManager": "pnpm@11.23.0"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ResizableNodeView } from "./resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
|
||||
import { normalizeFileUrl } from "./media-utils";
|
||||
|
||||
export type DrawioResizeOptions = {
|
||||
enabled: boolean;
|
||||
@@ -293,8 +293,6 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
const container = nodeView.dom as HTMLElement;
|
||||
applyAlignment(container, align);
|
||||
|
||||
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
|
||||
|
||||
currentNode = updatedNode;
|
||||
return true;
|
||||
},
|
||||
@@ -312,8 +310,6 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
syncAltBadge(nodeView.wrapper, node.attrs.alt);
|
||||
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
// Handle percentage width backward compat
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ResizableNodeView } from "./resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl, syncAltBadge } from "./media-utils";
|
||||
import { normalizeFileUrl } from "./media-utils";
|
||||
|
||||
export type ExcalidrawResizeOptions = {
|
||||
enabled: boolean;
|
||||
@@ -293,8 +293,6 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
const container = nodeView.dom as HTMLElement;
|
||||
applyAlignment(container, align);
|
||||
|
||||
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
|
||||
|
||||
currentNode = updatedNode;
|
||||
return true;
|
||||
},
|
||||
@@ -312,8 +310,6 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
syncAltBadge(nodeView.wrapper, node.attrs.alt);
|
||||
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
// Handle percentage width backward compat
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@tiptap/core";
|
||||
import { ResizableNodeView } from "../resizable-nodeview";
|
||||
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
|
||||
import { normalizeFileUrl, syncAltBadge } from "../media-utils";
|
||||
import { normalizeFileUrl } from "../media-utils";
|
||||
|
||||
export type ImageResizeOptions = {
|
||||
enabled: boolean;
|
||||
@@ -316,8 +316,6 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
const container = nodeView.dom as HTMLElement;
|
||||
applyAlignment(container, align);
|
||||
|
||||
syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt);
|
||||
|
||||
currentNode = updatedNode;
|
||||
return true;
|
||||
},
|
||||
@@ -335,8 +333,6 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
|
||||
const dom = nodeView.dom as HTMLElement;
|
||||
|
||||
syncAltBadge(nodeView.wrapper, node.attrs.alt);
|
||||
|
||||
// Apply initial alignment
|
||||
applyAlignment(dom, node.attrs.align || "center");
|
||||
|
||||
|
||||
@@ -7,27 +7,6 @@ export function normalizeFileUrl(src: string): string {
|
||||
return src || "";
|
||||
}
|
||||
|
||||
export function syncAltBadge(wrapper: HTMLElement, alt: unknown): void {
|
||||
const existing = wrapper.querySelector<HTMLElement>(
|
||||
":scope > .media-alt-badge",
|
||||
);
|
||||
|
||||
if (typeof alt !== "string" || !alt.trim()) {
|
||||
existing?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const badge = existing ?? document.createElement("span");
|
||||
badge.dataset.alt = alt;
|
||||
|
||||
if (!existing) {
|
||||
badge.className = "media-alt-badge";
|
||||
badge.textContent = "ALT";
|
||||
badge.setAttribute("aria-hidden", "true");
|
||||
wrapper.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
export type UploadFn = (
|
||||
file: File,
|
||||
editor: Editor,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user