diff --git a/Dockerfile b/Dockerfile index c8f6a6f46..33cac2225 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.15.1 +RUN npm install -g pnpm@11.25.0 FROM base AS builder @@ -18,6 +18,15 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends curl bash \ && rm -rf /var/lib/apt/lists/* +# drop npm and corepack +RUN rm -rf /usr/local/lib/node_modules/npm \ + && rm -rf /usr/local/lib/node_modules/corepack \ + && rm -rf /usr/local/bin/npm \ + && rm -rf /usr/local/bin/npx \ + && rm -rf /usr/local/bin/corepack \ + && rm -rf /root/.npm \ + && rm -rf /root/.node-gyp + WORKDIR /app # Copy apps diff --git a/apps/client/package.json b/apps/client/package.json index cca0ef4be..188aabde3 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -21,6 +21,7 @@ "@docmost/base-formula": "workspace:*", "@docmost/editor-ext": "workspace:*", "@excalidraw/excalidraw": "0.18.0-3a5ef40", + "@fontsource-variable/inter": "5.3.0", "@mantine/core": "9.3.2", "@mantine/dates": "9.3.2", "@mantine/form": "9.3.2", @@ -64,6 +65,7 @@ "react-router-dom": "7.18.2", "semver": "7.7.4", "socket.io-client": "4.8.3", + "yet-another-react-lightbox": "^3.32.2", "zod": "4.3.6" }, "devDependencies": { diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json index f067ecc27..3a9272d1e 100644 --- a/apps/client/public/locales/de-DE/translation.json +++ b/apps/client/public/locales/de-DE/translation.json @@ -294,6 +294,7 @@ "Export space": "Bereich exportieren", "Export {{type}}": "Exportiere {{type}}", "File exceeds the {{limit}} attachment limit": "Datei überschreitet das Anhängelimit von {{limit}}", + "Media": "Medien", "Align left": "Links ausrichten", "Align right": "Rechts ausrichten", "Align center": "Zentrieren", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Horizontale Trennlinie einfügen", "Page break": "Seitenumbruch", "Insert a page break for printing.": "Einen Seitenumbruch zum Drucken einfügen.", + "Footnote": "Fußnote", + "Insert a footnote reference.": "Einen Fußnotenverweis einfügen.", "Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.", "Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.", "Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Upgrade Ihres Plans", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "KI ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "KI ist in den kostenpflichtigen Docmost-Editionen verfügbar. Kontaktieren Sie sales@docmost.com.", "AI & MCP": "KI & MCP", "AI": "KI", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Aktivieren Sie den MCP-Server, damit KI-Assistenten und -Tools mit den Inhalten Ihres Arbeitsbereichs interagieren können.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP ist nur in der Docmost Enterprise-Edition verfügbar. Kontaktieren Sie sales@docmost.com.", "MCP Server URL": "MCP-Server-URL", - "Use your API key for authentication. You can manage API keys in your account settings.": "Verwenden Sie Ihren API-Schlüssel zur Authentifizierung. API-Schlüssel können in Ihren Kontoeinstellungen verwaltet werden.", + "Connect AI assistants with your Docmost account via OAuth.": "Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.", + "Enforce OAuth": "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", "Supported tools": "Unterstützte Tools", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In Ihrem Arbeitsbereich ist MCP aktiviert. Verwenden Sie Ihren API-Schlüssel, um KI-Assistenten anzubinden.", "MCP server URL:": "MCP-Server-URL:", "Learn more": "Mehr erfahren", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Verwalten Sie API-Schlüssel für alle Nutzer im Arbeitsbereich. Siehe die API-Dokumentation für Details zur Verwendung.", @@ -1289,5 +1294,48 @@ "{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} selected_other": "{{count}} selected", + "Compare": "Vergleichen", + "Compare versions": "Versionen vergleichen", + "Select version from {{date}}": "Version vom {{date}} auswählen", + "Version actions for {{date}}": "Versionsaktionen für {{date}}", + "Comparing {{newer}} and {{older}}": "{{newer}} und {{older}} werden verglichen", + "Exit compare": "Vergleich beenden", + "Search attachments...": "Anhänge suchen...", + "Error loading attachments.": "Fehler beim Laden der Anhänge.", + "No attachments on this page yet.": "Auf dieser Seite gibt es noch keine Anhänge.", + "Uploaded by {{name}}": "Hochgeladen von {{name}}", + "Download {{name}}": "{{name}} herunterladen", + "Access revoked": "Zugriff widerrufen", + "Authorize application": "Anwendung autorisieren", + "{{name}} wants to access {{workspace}}": "{{name}} möchte auf {{workspace}} zugreifen", + "Not you? Switch account": "Nicht du? Konto wechseln", + "This application will be able to:": "Diese Anwendung kann Folgendes:", + "Write": "Schreiben", + "Invalid authorization request": "Ungültige Autorisierungsanfrage", + "Authorize": "Autorisieren", + "Application": "Anwendung", + "Permissions": "Berechtigungen", + "Authorized": "Autorisiert", + "Revoke access": "Zugriff widerrufen", + "Revoke access for {{name}}": "Zugriff für {{name}} widerrufen", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Möchtest du den Zugriff für {{name}} wirklich widerrufen? Die Anwendung kann dann nicht mehr auf dein Konto zugreifen.", + "Something went wrong. Please try again.": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + "Remove {{name}}": "{{name}} entfernen", + "Make sure you trust this application before authorizing it.": "Stelle sicher, dass du dieser Anwendung vertraust, bevor du sie autorisierst.", + "You will be redirected to": "Du wirst weitergeleitet zu", + "View content without making changes.": "Inhalte ansehen, ohne Änderungen vorzunehmen.", + "Create and modify content.": "Inhalte erstellen und bearbeiten.", + "Applications and AI assistants you have authorized to access your account.": "Anwendungen und AI-Assistenten, denen du den Zugriff auf dein Konto autorisiert hast.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP ist für deinen Workspace aktiviert. Verbinde AI-Assistenten über OAuth mit deinem Docmost-Konto.", + "Authorized apps": "Autorisierte Apps", + "No authorized apps yet.": "Noch keine autorisierten Apps.", + "Workspace knowledge only": "Nur Workspace-Wissen", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Beschränke den AI-Chat darauf, nur anhand deiner Workspace-Seiten und hochgeladenen Dateien zu antworten. Es wird kein externes Wissen verwendet.", + "Toggle workspace knowledge only": "Nur Workspace-Wissen umschalten", + "Read-only mode": "Schreibgeschützter Modus", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI-Chat kann Workspace-Inhalte durchsuchen und lesen, aber keine Seiten erstellen oder bearbeiten.", + "Toggle AI Chat read-only mode": "Schreibgeschützten Modus für AI-Chat umschalten", + "Title only": "Nur Titel", + "you": "du" } diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 03e10c53e..3dc11c1b3 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -47,24 +47,24 @@ "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.", "Description": "Description", "Details": "Details", - "e.g ACME": "e.g ACME", - "e.g ACME Inc": "e.g ACME Inc", - "e.g Developers": "e.g Developers", - "e.g Group for developers": "e.g Group for developers", - "e.g product": "e.g product", - "e.g Product Team": "e.g Product Team", - "e.g Sales": "e.g Sales", - "e.g Space for product team": "e.g Space for product team", - "e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate", + "e.g ACME": "e.g. ACME", + "e.g ACME Inc": "e.g. ACME Inc", + "e.g Developers": "e.g. Developers", + "e.g Group for developers": "e.g. Group for developers", + "e.g product": "e.g. product", + "e.g Product Team": "e.g. Product Team", + "e.g Sales": "e.g. Sales", + "e.g Space for product team": "e.g. Space for product team", + "e.g Space for sales team to collaborate": "e.g. Space for sales team to collaborate", "Edit": "Edit", "Read": "Read", "Edit group": "Edit group", "Email": "Email", "Enter a strong password": "Enter a strong password", "Enter valid email addresses separated by comma or space max_50": "Enter valid email addresses separated by comma or space [max: 50]", - "enter valid emails addresses": "enter valid emails addresses", + "enter valid emails addresses": "Enter valid email addresses", "Enter your current password": "Enter your current password", - "enter your full name": "enter your full name", + "enter your full name": "Enter your full name", "Enter your new password": "Enter your new password", "Enter your new preferred email": "Enter your new preferred email", "Enter your password": "Enter your password", @@ -112,7 +112,7 @@ "Import pages": "Import pages", "Import pages & space settings": "Import pages & space settings", "Importing pages": "Importing pages", - "invalid invitation link": "invalid invitation link", + "invalid invitation link": "Invalid invitation link", "Invitation signup": "Invitation signup", "Invite by email": "Invite by email", "Invite members": "Invite members", @@ -223,7 +223,7 @@ "Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.", "Sidebar toggle": "Sidebar toggle", "Comments": "Comments", - "404 page not found": "404 page not found", + "404 page not found": "404! Page not found", "Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.", "Take me back to homepage": "Take me back to homepage", "Forgot password": "Forgot password", @@ -294,6 +294,7 @@ "Export space": "Export space", "Export {{type}}": "Export {{type}}", "File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit", + "Media": "Media", "Align left": "Align left", "Align right": "Align right", "Align center": "Align center", @@ -422,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", @@ -563,7 +564,7 @@ "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Make sure to save these codes in a secure place. Your old backup codes are no longer valid.", "Your new backup codes": "Your new backup codes", "I've saved my backup codes": "I've saved my backup codes", - "Failed to setup MFA": "Failed to setup MFA", + "Failed to setup MFA": "Failed to set up MFA", "Setup & Verify": "Setup & Verify", "Add to authenticator": "Add to authenticator", "1. Scan this QR code with your authenticator app": "1. Scan this QR code with your authenticator app", @@ -637,7 +638,7 @@ "Enterprise": "Enterprise", "Download attachment": "Download attachment", "Allowed email domains": "Allowed email domains", - "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can signup via SSO.", + "Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can sign up via SSO.", "Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space", "Enforce two-factor authentication": "Enforce two-factor authentication", "Once enforced, all members must enable two-factor authentication to access the workspace.": "Once enforced, all members must enable two-factor authentication to access the workspace.", @@ -698,7 +699,7 @@ "Upgrade your plan": "Upgrade your plan", "Available with a paid license": "Available with a paid license", "Upgrade your license tier.": "Upgrade your license tier.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is available in the Docmost paid editions. Contact sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", "MCP": "MCP", @@ -706,9 +707,11 @@ "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", - "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.", + "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", "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 API documentation for usage details.": "Manage API keys for all users in the workspace. View the API documentation for usage details.", @@ -790,6 +793,10 @@ "Removed page restriction": "Removed page restriction", "Added page permission": "Added page permission", "Removed page permission": "Removed page permission", + "Changed page permission": "Changed page permission", + "Requested password reset": "Requested password reset", + "Created template": "Created template", + "Deleted template": "Deleted template", "day": "day", "days": "days", "week": "week", @@ -826,7 +833,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", @@ -877,6 +884,9 @@ "{{name}} returned a page for revision": "{{name}} returned a page for revision", "Page verification expires soon": "Page verification expires soon", "Page verification has expired": "Page verification has expired", + "SIEM destination {{name}} is failing": "SIEM destination {{name}} is failing", + "SIEM destination {{name}} was disabled after 24 hours of failures": "SIEM destination {{name}} was disabled after 24 hours of failures", + "SIEM destination {{name}} recovered": "SIEM destination {{name}} recovered", "Verifying your email": "Verifying your email", "Please wait...": "Please wait...", "Verification failed. The link may have expired.": "Verification failed. The link may have expired.", @@ -902,7 +912,7 @@ "Publish": "Publish", "Security": "Security", "Enforce SSO": "Enforce SSO", - "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password.", + "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.", "AI-generated content may not be accurate.": "AI-generated content may not be accurate.", "AI Chat": "AI Chat", "Analyze for insights": "Analyze for insights", @@ -1248,7 +1258,7 @@ "Not started": "Not started", "Number": "Number", "One month ago": "One month ago", - "One month from now": "One month from now", + "One month from now": "In one month", "One week ago": "One week ago", "One week from now": "One week from now", "Open as page": "Open as page", @@ -1302,5 +1312,169 @@ "Error loading attachments.": "Error loading attachments.", "No attachments on this page yet.": "No attachments on this page yet.", "Uploaded by {{name}}": "Uploaded by {{name}}", - "Download {{name}}": "Download {{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", + "Read-only mode": "Read-only mode", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat can search and read workspace content, but cannot create or edit pages.", + "Toggle AI Chat read-only mode": "Toggle AI Chat read-only mode", + "Title only": "Title only", + "you": "you", + "Allow public spaces": "Allow public spaces", + "Space admins can publish their spaces to the web.": "Space admins can publish their spaces to the web.", + "Toggle allow public spaces": "Toggle allow public spaces", + "Publish space to the web": "Publish space to the web", + "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?": "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?", + "Make this space publicly readable by anyone on the internet.": "Make this space publicly readable by anyone on the internet.", + "Toggle publish space to the web": "Toggle publish space to the web", + "Allow search engines to index": "Allow search engines to index", + "Let public pages in this space appear in search engine results.": "Let public pages in this space appear in search engine results.", + "Toggle search engine indexing": "Toggle search engine indexing", + "Public space link": "Public space link", + "Copy public space link": "Copy public space link", + "Renaming the space slug will break public links.": "Renaming the space slug will break public links.", + "Failed to update space": "Failed to update space", + "This space is public": "This space is public", + "Anyone on the internet can read the pages in this space, except restricted pages.": "Anyone on the internet can read the pages in this space, except restricted pages.", + "Open public site": "Open public site", + "Public": "Public", + "This space has no public pages yet.": "This space has no public pages yet.", + "On this page": "On this page", + "Previous": "Previous", + "Next": "Next", + "Show hidden pages": "Show hidden pages", + "Page navigation": "Page navigation", + "Toggle sidebar": "Toggle sidebar", + "Toggle table of contents": "Toggle table of contents", + "Appearance": "Appearance", + "Forest": "Forest", + "Violet": "Violet", + "Light mode color": "Light mode color", + "Dark mode color": "Dark mode color", + "Choose the primary color of the public docs site.": "Choose the primary color of the public docs site.", + "Show page author": "Show page author", + "Display the page creator's name on public pages.": "Display the page creator's name on public pages.", + "Toggle show page author": "Toggle show page author", + "Show last updated": "Show last updated", + "Display when each page was last updated.": "Display when each page was last updated.", + "Toggle show last updated": "Toggle show last updated", + "Open public page": "Open public page", + "Toggle color scheme": "Toggle color scheme", + "Ember": "Ember", + "Rose": "Rose", + "Documentation": "Documentation", + "All published spaces.": "All published spaces.", + "No public spaces yet.": "No public spaces yet.", + "Show public directory": "Show public directory", + "List published spaces at /docs for anyone to browse.": "List published spaces at /docs for anyone to browse.", + "Toggle show public directory": "Toggle show public directory", + "Show in public directory": "Show in public directory", + "List this space in the public directory at /docs.": "List this space in the public directory at /docs.", + "Toggle show in public directory": "Toggle show in public directory", + "Open public space link": "Open public space link", + "Disable public spaces": "Disable public spaces", + "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?": "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?", + "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?": "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?", + "Allow": "Allow", + "Shared pages": "Shared pages", + "Published spaces": "Published spaces", + "Spaces published to the web will appear here": "Spaces published to the web will appear here", + "No published spaces": "No published spaces", + "Published by": "Published by", + "Published at": "Published at", + "Open space": "Open space", + "Unpublish": "Unpublish", + "Unpublish space": "Unpublish space", + "This space will no longer be publicly accessible. Are you sure?": "This space will no longer be publicly accessible. Are you sure?", + "More options for {{name}}": "More options for {{name}}", + "Edit page": "Edit page", + "Sign in": "Sign in", + "Open app": "Open app", + "No spaces match your search.": "No spaces match your search.", + "Welcome to our documentation": "Welcome to our documentation", + "Guides, references and answers across all our published spaces.": "Guides, references and answers across all our published spaces.", + "Guides, references and answers across all our spaces.": "Guides, references and answers across all our spaces.", + "Search documentation...": "Search documentation...", + "1 published space": "1 published space", + "{{count}} published spaces": "{{count}} published spaces", + "Actions": "Actions", + "Add destination": "Add destination", + "Are you sure you want to delete the destination": "Are you sure you want to delete the destination", + "Audit logs": "Audit logs", + "Audit logs & SIEM": "Audit logs & SIEM", + "Auth header name": "Auth header name", + "Auth header prefix": "Auth header prefix", + "Body format": "Body format", + "Created SIEM destination": "Created SIEM destination", + "Datadog site": "Datadog site", + "Defaults to this instance's hostname": "Defaults to this instance's hostname", + "Delete destination": "Delete destination", + "Deleted SIEM destination": "Deleted SIEM destination", + "Destination created": "Destination created", + "Destination deleted": "Destination deleted", + "Destination updated": "Destination updated", + "Disabled": "Disabled", + "Edit destination": "Edit destination", + "Endpoint URL": "Endpoint URL", + "Failing": "Failing", + "Failing since {{time}}": "Failing since {{time}}", + "HEC token": "HEC token", + "HEC URL": "HEC URL", + "Healthy": "Healthy", + "Hide advanced options": "Hide advanced options", + "Host": "Host", + "Index": "Index", + "Insecure: connections can be intercepted.": "Insecure: connections can be intercepted.", + "JSON array": "JSON array", + "Last delivered": "Last delivered", + "Last error": "Last error", + "Leave empty to use the token's default index": "Leave empty to use the token's default index", + "Maximum of {{limit}} destinations reached": "Maximum of {{limit}} destinations reached", + "Could not load SIEM destinations: {{message}}": "Could not load SIEM destinations: {{message}}", + "No destinations yet": "No destinations yet", + "Preset": "Preset", + "Retry now": "Retry now", + "Retry scheduled": "Retry scheduled", + "Send test event": "Send test event", + "Sent in the auth header below. Leave empty if your receiver does not need one.": "Sent in the auth header below. Leave empty if your receiver does not need one.", + "Service": "Service", + "Show advanced options": "Show advanced options", + "SIEM": "SIEM", + "SIEM streaming": "SIEM streaming", + "SIEM streaming requires an Enterprise license.": "SIEM streaming requires an Enterprise license.", + "Source": "Source", + "Sourcetype": "Sourcetype", + "Tags": "Tags", + "Test connection": "Test connection", + "Test event delivered successfully.": "Test event delivered successfully.", + "Test the connection before saving.": "Test the connection before saving.", + "Test event delivered to {{name}}": "Test event delivered to {{name}}", + "Updated SIEM destination": "Updated SIEM destination", + "Verify TLS certificate": "Verify TLS certificate", + "e.g. Splunk prod": "e.g. Splunk prod" } diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json index fd834a2c6..d7093bf3e 100644 --- a/apps/client/public/locales/es-ES/translation.json +++ b/apps/client/public/locales/es-ES/translation.json @@ -294,6 +294,7 @@ "Export space": "Exportar espacio", "Export {{type}}": "Exportar {{type}}", "File exceeds the {{limit}} attachment limit": "El archivo supera el límite de {{limit}} adjuntos", + "Media": "Multimedia", "Align left": "Alinear a la izquierda", "Align right": "Alinear a la derecha", "Align center": "Alinear al centro", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Insertar regla horizontal", "Page break": "Salto de página", "Insert a page break for printing.": "Inserta un salto de página para imprimir.", + "Footnote": "Nota al pie", + "Insert a footnote reference.": "Insertar una referencia de nota al pie.", "Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.", "Upload any video from your device.": "Sube cualquier video desde tu dispositivo.", "Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Mejora tu plan", "Available with a paid license": "Disponible con una licencia de pago", "Upgrade your license tier.": "Mejora el nivel de tu licencia.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "La IA solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "La IA está disponible en las ediciones de pago de Docmost. Contacte con sales@docmost.com.", "AI & MCP": "IA y MCP", "AI": "IA", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Habilite el servidor MCP para permitir que asistentes de IA y herramientas interactúen con el contenido de su espacio de trabajo.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP solo está disponible en la edición empresarial de Docmost. Contacte con sales@docmost.com.", "MCP Server URL": "URL del servidor MCP", - "Use your API key for authentication. You can manage API keys in your account settings.": "Use su clave API para la autenticación. Puede gestionar las claves API en la configuración de su cuenta.", + "Connect AI assistants with your Docmost account via OAuth.": "Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.", + "Enforce OAuth": "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", "Supported tools": "Herramientas compatibles", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Su espacio de trabajo tiene MCP habilitado. Use su clave API para conectar asistentes de IA.", "MCP server URL:": "URL del servidor MCP:", "Learn more": "Más información", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Gestiona las claves de API para todos los usuarios en el espacio de trabajo. Consulta la documentación de la API para detalles de uso.", @@ -1186,8 +1191,8 @@ "Default value": "Valor predeterminado", "Delete property": "Eliminar propiedad", "Delete view": "Eliminar vista", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "¿Eliminar 1 fila?", + "Delete {{count}} rows?_other": "¿Eliminar {{count}} filas?", "Descending": "Descendente", "Discard": "Descartar", "Doesn't contain": "No contiene", @@ -1286,8 +1291,51 @@ "Value": "Valor", "View updated for everyone": "Vista actualizada para todos", "You have unsaved changes. Do you want to discard them?": "Tiene cambios no guardados. ¿Quiere descartarlos?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} rows deleted_one": "1 fila eliminada", + "{{count}} rows deleted_other": "{{count}} filas eliminadas", + "{{count}} selected_one": "1 seleccionado", + "{{count}} selected_other": "{{count}} seleccionadas", + "Compare": "Comparar", + "Compare versions": "Comparar versiones", + "Select version from {{date}}": "Seleccionar la versión del {{date}}", + "Version actions for {{date}}": "Acciones de la versión del {{date}}", + "Comparing {{newer}} and {{older}}": "Comparando {{newer}} y {{older}}", + "Exit compare": "Salir de la comparación", + "Search attachments...": "Buscar archivos adjuntos...", + "Error loading attachments.": "Error al cargar los archivos adjuntos.", + "No attachments on this page yet.": "Todavía no hay archivos adjuntos en esta página.", + "Uploaded by {{name}}": "Subido por {{name}}", + "Download {{name}}": "Descargar {{name}}", + "Access revoked": "Acceso revocado", + "Authorize application": "Autorizar aplicación", + "{{name}} wants to access {{workspace}}": "{{name}} quiere acceder a {{workspace}}", + "Not you? Switch account": "¿No eres tú? Cambiar de cuenta", + "This application will be able to:": "Esta aplicación podrá:", + "Write": "Escribir", + "Invalid authorization request": "Solicitud de autorización no válida", + "Authorize": "Autorizar", + "Application": "Aplicación", + "Permissions": "Permisos", + "Authorized": "Autorizado", + "Revoke access": "Revocar acceso", + "Revoke access for {{name}}": "Revocar acceso para {{name}}", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "¿Seguro que quieres revocar el acceso de {{name}}? La aplicación ya no podrá acceder a tu cuenta.", + "Something went wrong. Please try again.": "Algo salió mal. Inténtalo de nuevo.", + "Remove {{name}}": "Eliminar {{name}}", + "Make sure you trust this application before authorizing it.": "Asegúrate de confiar en esta aplicación antes de autorizarla.", + "You will be redirected to": "Serás redirigido a", + "View content without making changes.": "Ver contenido sin realizar cambios.", + "Create and modify content.": "Crear y modificar contenido.", + "Applications and AI assistants you have authorized to access your account.": "Aplicaciones y asistentes de IA que has autorizado para acceder a tu cuenta.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Tu espacio de trabajo tiene MCP habilitado. Conecta asistentes de IA con tu cuenta de Docmost mediante OAuth.", + "Authorized apps": "Aplicaciones autorizadas", + "No authorized apps yet.": "Todavía no hay aplicaciones autorizadas.", + "Workspace knowledge only": "Solo conocimiento del espacio de trabajo", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restringe AI Chat para que responda solo con información de las páginas y archivos subidos de tu espacio de trabajo. No usará conocimiento externo.", + "Toggle workspace knowledge only": "Alternar solo conocimiento del espacio de trabajo", + "Read-only mode": "Modo de solo lectura", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat puede buscar y leer el contenido del espacio de trabajo, pero no puede crear ni editar páginas.", + "Toggle AI Chat read-only mode": "Alternar modo de solo lectura de AI Chat", + "Title only": "Solo título", + "you": "tú" } diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 04ebbc6ad..76b80d4be 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -294,6 +294,7 @@ "Export space": "Exporter l'espace", "Export {{type}}": "Exporter {{type}}", "File exceeds the {{limit}} attachment limit": "Le fichier dépasse la limite de {{limit}} pièces jointes", + "Media": "Médias", "Align left": "Aligner à gauche", "Align right": "Aligner à droite", "Align center": "Aligner au centre", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Insérer un séparateur de règle horizontale", "Page break": "Saut de page", "Insert a page break for printing.": "Insérer un saut de page pour l’impression.", + "Footnote": "Note de bas de page", + "Insert a footnote reference.": "Insérer une référence de note de bas de page.", "Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.", "Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.", "Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Mettez à niveau votre forfait", "Available with a paid license": "Disponible avec une licence payante", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA est disponible dans les éditions payantes de Docmost. Contactez sales@docmost.com.", "AI & MCP": "IA & MCP", "AI": "IA", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Activez le serveur MCP pour permettre aux assistants et outils IA d'interagir avec le contenu de votre espace de travail.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP n'est disponible que dans l'édition Entreprise de Docmost. Contactez sales@docmost.com.", "MCP Server URL": "URL du serveur MCP", - "Use your API key for authentication. You can manage API keys in your account settings.": "Utilisez votre clé API pour l'authentification. Vous pouvez gérer les clés API dans les paramètres de votre compte.", + "Connect AI assistants with your Docmost account via OAuth.": "Connectez des assistants IA à votre compte Docmost via OAuth.", + "Enforce OAuth": "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", "Supported tools": "Outils pris en charge", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Votre espace de travail a MCP activé. Utilisez votre clé API pour connecter des assistants IA.", "MCP server URL:": "URL du serveur MCP :", "Learn more": "En savoir plus", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Gérez les clés API pour tous les utilisateurs de l'espace de travail. Consultez la documentation API pour plus de détails sur l'utilisation.", @@ -1186,8 +1191,8 @@ "Default value": "Valeur par défaut", "Delete property": "Supprimer la propriété", "Delete view": "Supprimer la vue", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "Supprimer 1 ligne ?", + "Delete {{count}} rows?_other": "Supprimer {{count}} lignes ?", "Descending": "Décroissant", "Discard": "Ignorer", "Doesn't contain": "Ne contient pas", @@ -1286,8 +1291,51 @@ "Value": "Valeur", "View updated for everyone": "Vue mise à jour pour tout le monde", "You have unsaved changes. Do you want to discard them?": "Vous avez des modifications non enregistrées. Voulez-vous les ignorer ?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} rows deleted_one": "1 ligne supprimée", + "{{count}} rows deleted_other": "{{count}} lignes supprimées", + "{{count}} selected_one": "1 sélectionné(e)", + "{{count}} selected_other": "{{count}} sélectionné(e)s", + "Compare": "Comparer", + "Compare versions": "Comparer les versions", + "Select version from {{date}}": "Sélectionner la version du {{date}}", + "Version actions for {{date}}": "Actions de version pour le {{date}}", + "Comparing {{newer}} and {{older}}": "Comparaison de {{newer}} et {{older}}", + "Exit compare": "Quitter la comparaison", + "Search attachments...": "Rechercher des pièces jointes...", + "Error loading attachments.": "Erreur lors du chargement des pièces jointes.", + "No attachments on this page yet.": "Aucune pièce jointe sur cette page pour le moment.", + "Uploaded by {{name}}": "Téléversé par {{name}}", + "Download {{name}}": "Télécharger {{name}}", + "Access revoked": "Accès révoqué", + "Authorize application": "Autoriser l’application", + "{{name}} wants to access {{workspace}}": "{{name}} souhaite accéder à {{workspace}}", + "Not you? Switch account": "Ce n’est pas vous ? Changer de compte", + "This application will be able to:": "Cette application pourra :", + "Write": "Écriture", + "Invalid authorization request": "Demande d’autorisation invalide", + "Authorize": "Autoriser", + "Application": "Application", + "Permissions": "Autorisations", + "Authorized": "Autorisé", + "Revoke access": "Révoquer l’accès", + "Revoke access for {{name}}": "Révoquer l’accès pour {{name}}", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Êtes-vous sûr de vouloir révoquer l’accès pour {{name}} ? L’application ne pourra plus accéder à votre compte.", + "Something went wrong. Please try again.": "Une erreur s’est produite. Veuillez réessayer.", + "Remove {{name}}": "Supprimer {{name}}", + "Make sure you trust this application before authorizing it.": "Assurez-vous de faire confiance à cette application avant de l’autoriser.", + "You will be redirected to": "Vous serez redirigé vers", + "View content without making changes.": "Afficher le contenu sans apporter de modifications.", + "Create and modify content.": "Créer et modifier du contenu.", + "Applications and AI assistants you have authorized to access your account.": "Applications et assistants IA que vous avez autorisés à accéder à votre compte.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP est activé dans votre espace de travail. Connectez des assistants IA à votre compte Docmost via OAuth.", + "Authorized apps": "Applications autorisées", + "No authorized apps yet.": "Aucune application autorisée pour le moment.", + "Workspace knowledge only": "Connaissances de l’espace de travail uniquement", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Limiter AI Chat aux réponses provenant uniquement des pages de votre espace de travail et des fichiers téléversés. Il n’utilisera pas de connaissances externes.", + "Toggle workspace knowledge only": "Activer/désactiver les connaissances de l’espace de travail uniquement", + "Read-only mode": "Mode lecture seule", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat peut rechercher et lire le contenu de l’espace de travail, mais ne peut pas créer ni modifier des pages.", + "Toggle AI Chat read-only mode": "Activer/désactiver le mode lecture seule d’AI Chat", + "Title only": "Titre uniquement", + "you": "vous" } diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json index e3aaa82ce..24dddd97d 100644 --- a/apps/client/public/locales/it-IT/translation.json +++ b/apps/client/public/locales/it-IT/translation.json @@ -205,7 +205,7 @@ "Templates": "Modelli", "Theme": "Tema", "To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.", - "Toggle full page width": "Attiva/disattiva larghezza completa della pagina", + "Toggle full page width": "Attiva/disattiva larghezza intera della pagina", "Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.", "untitled": "senza titolo", "Untitled": "Senza titolo", @@ -294,6 +294,7 @@ "Export space": "Esporta spazio", "Export {{type}}": "Esporta {{type}}", "File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}", + "Media": "Contenuti multimediali", "Align left": "Allinea a sinistra", "Align right": "Allinea a destra", "Align center": "Allinea al centro", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Inserisci divisore di regola orizzontale", "Page break": "Interruzione di pagina", "Insert a page break for printing.": "Inserisci un'interruzione di pagina per la stampa.", + "Footnote": "Nota a piè di pagina", + "Insert a footnote reference.": "Inserisci un riferimento a una nota a piè di pagina.", "Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.", "Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.", "Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Aggiorna il tuo piano", "Available with a paid license": "Disponibile con una licenza a pagamento", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "L'IA è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "L'IA è disponibile nelle edizioni a pagamento di Docmost. Contatta sales@docmost.com.", "AI & MCP": "IA e MCP", "AI": "IA", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Abilita il server MCP per consentire ad assistenti e strumenti IA di interagire con i contenuti del tuo spazio di lavoro.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP è disponibile solo nell'edizione Enterprise di Docmost. Contatta sales@docmost.com.", "MCP Server URL": "URL del server MCP", - "Use your API key for authentication. You can manage API keys in your account settings.": "Usa la tua chiave API per l'autenticazione. Puoi gestire le chiavi API nelle impostazioni del tuo account.", + "Connect AI assistants with your Docmost account via OAuth.": "Connetti gli assistenti AI al tuo account Docmost tramite OAuth.", + "Enforce OAuth": "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", "Supported tools": "Strumenti supportati", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Il tuo spazio di lavoro ha MCP abilitato. Usa la tua chiave API per collegare gli assistenti IA.", "MCP server URL:": "URL del server MCP:", "Learn more": "Scopri di più", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Gestisci le API key per tutti gli utenti nello spazio di lavoro. Consulta la documentazione API per i dettagli sull'utilizzo.", @@ -1186,8 +1191,8 @@ "Default value": "Valore predefinito", "Delete property": "Elimina proprietà", "Delete view": "Elimina vista", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "Eliminare 1 riga?", + "Delete {{count}} rows?_other": "Eliminare {{count}} righe?", "Descending": "Decrescente", "Discard": "Ignora", "Doesn't contain": "Non contiene", @@ -1286,8 +1291,51 @@ "Value": "Valore", "View updated for everyone": "Vista aggiornata per tutti", "You have unsaved changes. Do you want to discard them?": "Hai modifiche non salvate. Vuoi ignorarle?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} rows deleted_one": "1 riga eliminata", + "{{count}} rows deleted_other": "{{count}} righe eliminate", + "{{count}} selected_one": "1 selezionato", + "{{count}} selected_other": "{{count}} selezionati", + "Compare": "Confronta", + "Compare versions": "Confronta versioni", + "Select version from {{date}}": "Seleziona la versione del {{date}}", + "Version actions for {{date}}": "Azioni della versione del {{date}}", + "Comparing {{newer}} and {{older}}": "Confronto tra {{newer}} e {{older}}", + "Exit compare": "Esci dal confronto", + "Search attachments...": "Cerca allegati...", + "Error loading attachments.": "Errore durante il caricamento degli allegati.", + "No attachments on this page yet.": "Ancora nessun allegato in questa pagina.", + "Uploaded by {{name}}": "Caricato da {{name}}", + "Download {{name}}": "Scarica {{name}}", + "Access revoked": "Accesso revocato", + "Authorize application": "Autorizza applicazione", + "{{name}} wants to access {{workspace}}": "{{name}} vuole accedere a {{workspace}}", + "Not you? Switch account": "Non sei tu? Cambia account", + "This application will be able to:": "Questa applicazione potrà:", + "Write": "Scrivere", + "Invalid authorization request": "Richiesta di autorizzazione non valida", + "Authorize": "Autorizza", + "Application": "Applicazione", + "Permissions": "Autorizzazioni", + "Authorized": "Autorizzato", + "Revoke access": "Revoca accesso", + "Revoke access for {{name}}": "Revoca l'accesso per {{name}}", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Sei sicuro di voler revocare l'accesso per {{name}}? L'applicazione non potrà più accedere al tuo account.", + "Something went wrong. Please try again.": "Qualcosa è andato storto. Riprova.", + "Remove {{name}}": "Rimuovi {{name}}", + "Make sure you trust this application before authorizing it.": "Assicurati di fidarti di questa applicazione prima di autorizzarla.", + "You will be redirected to": "Verrai reindirizzato a", + "View content without making changes.": "Visualizza il contenuto senza apportare modifiche.", + "Create and modify content.": "Crea e modifica contenuti.", + "Applications and AI assistants you have authorized to access your account.": "Applicazioni e assistenti AI che hai autorizzato ad accedere al tuo account.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Il tuo workspace ha MCP abilitato. Connetti gli assistenti AI al tuo account Docmost tramite OAuth.", + "Authorized apps": "App autorizzate", + "No authorized apps yet.": "Ancora nessuna app autorizzata.", + "Workspace knowledge only": "Solo conoscenze del workspace", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Limita AI Chat a rispondere solo in base alle pagine del tuo workspace e ai file caricati. Non userà conoscenze esterne.", + "Toggle workspace knowledge only": "Attiva/disattiva solo conoscenze del workspace", + "Read-only mode": "Modalità di sola lettura", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat può cercare e leggere i contenuti del workspace, ma non può creare o modificare pagine.", + "Toggle AI Chat read-only mode": "Attiva/disattiva la modalità di sola lettura di AI Chat", + "Title only": "Solo titolo", + "you": "tu" } diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json index e408bfda8..f29f01b04 100644 --- a/apps/client/public/locales/ja-JP/translation.json +++ b/apps/client/public/locales/ja-JP/translation.json @@ -294,6 +294,7 @@ "Export space": "エクスポートスペース", "Export {{type}}": "{{type}}をエクスポート", "File exceeds the {{limit}} attachment limit": "ファイルが{{limit}}の添付制限を超えています", + "Media": "メディア", "Align left": "左揃え", "Align right": "右揃え", "Align center": "中央揃え", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "区切り線を挿入します", "Page break": "改ページ", "Insert a page break for printing.": "印刷用に改ページを挿入します。", + "Footnote": "脚注", + "Insert a footnote reference.": "脚注参照を挿入します。", "Upload any image from your device.": "デバイスから画像をアップロードします", "Upload any video from your device.": "デバイスから動画をアップロードします", "Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。", @@ -696,7 +699,7 @@ "Upgrade your plan": "プランをアップグレードする", "Available with a paid license": "有料ライセンスで利用可能", "Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI は Docmost の有料版で利用可能です。sales@docmost.com までお問い合わせください。", "AI & MCP": "AI と MCP", "AI": "AI", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "MCP サーバーを有効にして、AI アシスタントやツールがワークスペースのコンテンツとやり取りできるようにします。", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP は Docmost のエンタープライズ版でのみ利用可能です。sales@docmost.com までお問い合わせください。", "MCP Server URL": "MCP サーバーの URL", - "Use your API key for authentication. You can manage API keys in your account settings.": "認証には API キーを使用してください。API キーはアカウント設定で管理できます。", + "Connect AI assistants with your Docmost account via OAuth.": "OAuth を使用して AI アシスタントを Docmost アカウントに接続します。", + "Enforce OAuth": "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 必須化を切り替え", "Supported tools": "サポートされているツール", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "このワークスペースでは MCP が有効になっています。AI アシスタントを接続するには API キーを使用してください。", "MCP server URL:": "MCP サーバーの URL:", "Learn more": "詳細を見る", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "ワークスペース内のすべてのユーザーのAPIキーを管理します。利用方法の詳細はAPIドキュメントをご覧ください。", @@ -1186,7 +1191,7 @@ "Default value": "デフォルト値", "Delete property": "プロパティを削除", "Delete view": "ビューを削除", - "Delete {{count}} rows?_one": "Delete 1 row?", + "Delete {{count}} rows?_one": "1行を削除しますか?", "Delete {{count}} rows?_other": "Delete {{count}} rows?", "Descending": "降順", "Discard": "破棄", @@ -1286,8 +1291,51 @@ "Value": "値", "View updated for everyone": "ビューが全員向けに更新されました", "You have unsaved changes. Do you want to discard them?": "未保存の変更があります。破棄しますか?", - "{{count}} rows deleted_one": "1 row deleted", + "{{count}} rows deleted_one": "1行を削除しました", "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{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": "あなた" } diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json index fb8e9884a..91ec95d3e 100644 --- a/apps/client/public/locales/ko-KR/translation.json +++ b/apps/client/public/locales/ko-KR/translation.json @@ -2,23 +2,23 @@ "Account": "계정", "Active": "활성", "Add": "추가", - "Add group members": "팀에 사용자 추가", - "Add groups": "팀 생성", + "Add group members": "그룹에 멤버 추가", + "Add groups": "그룹 추가", "Add members": "사용자 추가", - "Add to groups": "팀에 추가", + "Add to groups": "그룹에 추가", "Add space members": "Space에 사용자 추가", "Add to favorites": "즐겨찾기에 추가", "Admin": "관리자", - "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 팀을 삭제하시겠습니까? 해당 팀에 속한 사용자들은 이 팀이 가진 모든 권한을 잃게 됩니다.", + "Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "이 그룹을 삭제하시겠습니까? 그룹 멤버는 이 그룹이 가진 모든 권한을 잃게 됩니다.", "Are you sure you want to delete this page?": "이 페이지를 삭제하시겠습니까?", - "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "이 사용자를 팀에서 제거하시겠습니까? 사용자는 이 팀이 가진 모든 권한을 잃게 됩니다.", + "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "이 사용자를 그룹에서 제거하시겠습니까? 사용자는 이 그룹이 가진 모든 권한을 잃게 됩니다.", "Are you sure you want to remove this user from the space? The user will lose all access to this space.": "이 사용자를 Space에서 제거하시겠습니까? 사용자는 이 Space에 대한 모든 접근 권한을 잃게 됩니다.", "Are you sure you want to restore this version? Any changes not versioned will be lost.": "이 버전으로 복원하시겠습니까? 저장되지 않은 모든 변경사항이 손실됩니다.", - "Can become members of groups and spaces in workspace": "Workspace 내 팀 및 Space의 사용자가 될 수 있습니다.", + "Can become members of groups and spaces in workspace": "워크스페이스의 그룹 및 스페이스의 멤버가 될 수 있음", "Can create and edit pages in space.": "Space에 페이지를 생성하고 편집할 수 있습니다.", "Can edit": "편집할 수 있음", - "Can manage workspace": "Workspace를 관리할 수 있음", - "Can manage workspace but cannot delete it": "Workspace를 관리할 수 있지만, 삭제는 불가능.", + "Can manage workspace": "워크스페이스를 관리할 수 있음", + "Can manage workspace but cannot delete it": "워크스페이스를 관리할 수 있지만, 삭제할 수 없음", "Can view": "볼 수 있음", "Can view pages in space but not edit.": "Space의 페이지를 볼 수 있지만, 편집은 불가능.", "Cancel": "취소", @@ -33,17 +33,17 @@ "Copy as Markdown": "Markdown으로 복사", "Copy link": "링크 복사", "Create": "생성", - "Create group": "팀 생성", + "Create group": "그룹 생성", "Create page": "페이지 생성", "Create space": "Space 생성", - "Create workspace": "Workspace 생성", + "Create workspace": "워크스페이스 생성", "Current password": "현재 비밀번호", "Dark": "어두운", "Date": "날짜", "Delete": "삭제", "Remove from page": "페이지에서 제거", "Base options": "베이스 옵션", - "Delete group": "팀 삭제", + "Delete group": "그룹 삭제", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "이 페이지를 삭제하시겠습니까? 하위 페이지와 페이지 기록이 모두 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", "Description": "설명", "Details": "세부사항", @@ -58,7 +58,7 @@ "e.g Space for sales team to collaborate": "예: 영업 팀이 협업하는 스페이스", "Edit": "편집", "Read": "읽기", - "Edit group": "팀 편집", + "Edit group": "그룹 편집", "Email": "이메일", "Enter a strong password": "강력한 비밀번호를 입력하세요", "Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]", @@ -103,10 +103,10 @@ "Full page width": "전체 페이지 너비", "Full width": "전체 너비", "General": "일반", - "Group": "팀", - "Group description": "팀 설명", - "Group name": "팀 이름", - "Groups": "팀", + "Group": "그룹", + "Group description": "그룹 설명", + "Group name": "그룹 이름", + "Groups": "그룹", "Has full access to space settings and pages.": "Space 설정과 페이지에 대한 전체 접근 권한이 있습니다.", "Home": "홈", "Import pages": "페이지 가져오기", @@ -119,14 +119,14 @@ "Invite new members": "새 사용자 초대", "Invite People": "사용자 초대", "Invited members who are yet to accept their invitation will appear here.": "초대를 아직 수락하지 않은 초대된 사용자가 여기에 표시됩니다.", - "Invited members will be granted access to spaces the groups can access": "초대된 사용자는 팀이 접근할 수 있는 Space에 대한 접근 권한을 받게 됩니다", - "Join the workspace": "Workspace 참여", + "Invited members will be granted access to spaces the groups can access": "초대된 멤버에게는 그룹이 접근할 수 있는 스페이스에 대한 액세스 권한이 부여됩니다", + "Join the workspace": "워크스페이스 참여", "Language": "언어", "Light": "밝은", "Link copied": "링크 복사됨", "Login": "로그인", "Logout": "로그아웃", - "Manage Group": "팀 관리", + "Manage Group": "그룹 관리", "Manage members": "사용자 관리", "member": "사용자", "Member": "사용자", @@ -139,7 +139,7 @@ "New email": "새 이메일", "New page": "새 페이지", "New password": "새 비밀번호", - "No group found": "그룹을 찾을 수 없습니다", + "No group found": "그룹을 찾을 수 없음", "No page history saved yet.": "아직 저장된 페이지 기록이 없습니다.", "No pages yet": "아직 페이지가 없습니다", "No shared pages": "공유된 페이지가 없습니다.", @@ -148,7 +148,7 @@ "Overview": "개요", "Owner": "소유자", "page": "페이지", - "Page deleted successfully": "페이지 삭제 완료", + "Page deleted successfully": "페이지 삭제됨", "Page history": "페이지 기록", "Select version": "버전 선택", "Highlight changes": "변경 사항 강조", @@ -156,7 +156,7 @@ "Pages": "페이지", "pages": "페이지", "Password": "비밀번호", - "Password changed successfully": "비밀번호 변경 완료", + "Password changed successfully": "비밀번호 변경됨", "People": "사용자", "Pending": "대기 중", "Please confirm your action": "작업을 확인해 주세요", @@ -166,15 +166,15 @@ "Recently updated": "최근 업데이트", "Remove": "제거", "Remove from favorites": "즐겨찾기에서 제거", - "Remove group member": "팀에서 사용자 제거", + "Remove group member": "그룹 멤버 제거", "Remove space member": "Space에서 사용자 제거", "Restore": "복원", "Role": "역할", "Save": "저장", "Search": "검색", - "Search for groups": "팀 검색", + "Search for groups": "그룹 검색", "Search for users": "사용자 검색", - "Search for users and groups": "사용자 및 팀 검색", + "Search for users and groups": "사용자 및 그룹 검색", "Search...": "검색...", "Select language": "언어 선택", "Select role": "역할 선택", @@ -199,8 +199,8 @@ "Search for spaces": "스페이스 검색", "Start typing to search...": "검색하려면 입력을 시작하세요...", "Status": "상태", - "Successfully imported": "가져오기에 성공했습니다", - "Successfully restored": "복원에 성공했습니다", + "Successfully imported": "가져오기 완료", + "Successfully restored": "복원 완료", "System settings": "시스템 설정", "Templates": "템플릿", "Theme": "테마", @@ -209,7 +209,7 @@ "Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.", "untitled": "제목 없음", "Untitled": "제목 없음", - "Updated successfully": "성공적으로 업데이트되었습니다", + "Updated successfully": "업데이트 완료", "User": "사용자", "Workspace": "워크스페이스", "Workspace Name": "워크스페이스 이름", @@ -245,15 +245,15 @@ "Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?", "Delete chat": "채팅 삭제", "Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", - "Comment created successfully": "댓글 생성 완료", + "Comment created successfully": "댓글 생성됨", "Error creating comment": "댓글 생성 오류", - "Comment updated successfully": "댓글 업데이트 완료", + "Comment updated successfully": "댓글 업데이트됨", "Failed to update comment": "댓글 업데이트 실패", - "Comment deleted successfully": "댓글 삭제 완료", + "Comment deleted successfully": "댓글 삭제됨", "Failed to delete comment": "댓글 삭제 실패", - "Comment resolved successfully": "댓글 처리 완료", - "Comment re-opened successfully": "댓글이 성공적으로 다시 열렸습니다", - "Comment unresolved successfully": "댓글 해결이 성공적으로 취소되었습니다", + "Comment resolved successfully": "댓글이 해결로 표시됨", + "Comment re-opened successfully": "댓글이 열린 상태로 표시됨", + "Comment unresolved successfully": "댓글이 미해결로 표시됨", "Failed to resolve comment": "댓글 처리 실패", "Resolve comment": "댓글 해결", "Unresolve comment": "댓글 해결 취소", @@ -263,19 +263,19 @@ "Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?", "Resolved": "해결됨", "No active comments.": "활성 댓글이 없습니다.", - "Revoke invitation": "초대 취소", - "Revoke": "취소", + "Revoke invitation": "초대 폐기", + "Revoke": "폐기", "Don't": "하지 않음", - "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.", + "Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 폐기하시겠습니까? 사용자가 워크스페이스에 참여할 수 없게 됩니다.", "Resend invitation": "초대 재전송", - "Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.", + "Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 워크스페이스에 참여할 수 있습니다.", "Invite link": "초대 링크", "Copy": "복사", "Copy to space": "스페이스로 복사", "Copied": "복사됨", "Duplicate": "복제", "Select a user": "사용자 선택", - "Select a group": "팀 선택", + "Select a group": "그룹 선택", "Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.", "Delete space": "Space 삭제", "Are you sure you want to delete this space?": "이 Space을 삭제하시겠습니까?", @@ -290,10 +290,11 @@ "Export failed:": "내보내기 실패:", "export error": "내보내기 오류", "Export page": "페이지 내보내기", - "Export successful": "내보내기 성공", + "Export successful": "내보내기 완료", "Export space": "Space 내보내기", "Export {{type}}": "{{type}} 내보내기", "File exceeds the {{limit}} attachment limit": "첨부 파일 크기 제한 {{limit}}을 초과했습니다", + "Media": "미디어", "Align left": "왼쪽 정렬", "Align right": "오른쪽 정렬", "Align center": "가운데 정렬", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "가로 구분선 삽입", "Page break": "페이지 나누기", "Insert a page break for printing.": "인쇄용 페이지 나누기를 삽입합니다.", + "Footnote": "각주", + "Insert a footnote reference.": "각주 참조를 삽입합니다.", "Upload any image from your device.": "기기에서 이미지를 업로드하세요.", "Upload any video from your device.": "기기에서 비디오를 업로드하세요.", "Upload any audio from your device.": "기기에서 오디오를 업로드하세요.", @@ -447,12 +450,12 @@ "Today, {{time}}": "오늘, {{time}}", "Yesterday, {{time}}": "어제, {{time}}", "now": "지금", - "Space created successfully": "스페이스가 성공적으로 생성되었습니다", - "Space updated successfully": "스페이스가 성공적으로 업데이트되었습니다", - "Space deleted successfully": "스페이스가 성공적으로 삭제되었습니다", - "Members added successfully": "멤버가 성공적으로 추가되었습니다", - "Member removed successfully": "멤버가 성공적으로 제거되었습니다", - "Member role updated successfully": "멤버 역할이 성공적으로 업데이트되었습니다", + "Space created successfully": "스페이스 생성됨", + "Space updated successfully": "스페이스 업데이트됨", + "Space deleted successfully": "스페이스 삭제됨", + "Members added successfully": "멤버 추가됨", + "Member removed successfully": "멤버 삭제됨", + "Member role updated successfully": "멤버 역할 업데이트됨", "Created by: {{creatorName}}": "작성자: {{creatorName}}", "Created at: {{time}}": "작성 시간: {{time}}", "Edited by {{name}} {{time}}": "{{name}}님이 {{time}}에 편집함", @@ -465,7 +468,7 @@ "Choose {{format}} file": "{{format}} 파일 선택", "Reading": "읽기", "Delete member": "멤버 삭제", - "Member deleted successfully": "멤버가 성공적으로 삭제되었습니다", + "Member deleted successfully": "멤버 삭제됨", "Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "Deactivate member": "멤버 비활성화", "Activate member": "멤버 활성화", @@ -498,32 +501,32 @@ "Delete share": "공유 삭제", "Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?", "Publicly shared pages from spaces you are a member of will appear here": "회원으로 속한 스페이스의 공개 공유 페이지가 여기에 표시됩니다", - "Share deleted successfully": "공유가 성공적으로 삭제되었습니다", + "Share deleted successfully": "공유 삭제됨", "Share not found": "공유를 찾을 수 없습니다", "Failed to share page": "페이지 공유에 실패했습니다", - "Disable public sharing": "공유 비활성화", - "Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.", - "Toggle public sharing": "공유 전환", - "Toggle space public sharing": "공간 공유 전환", + "Disable public sharing": "공개 공유 비활성화", + "Prevent members from sharing pages publicly.": "멤버가 페이지를 공개적으로 공유하지 못하게 합니다.", + "Toggle public sharing": "공개 공유 전환", + "Toggle space public sharing": "스페이스 공개 공유 전환", "Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용", "Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.", "Toggle viewer comments": "뷰어 댓글 전환", - "Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.", - "Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.", + "Public sharing is disabled at the workspace level": "공개 공유가 워크스페이스 수준에서 비활성화됨", + "Prevent pages in this space from being shared publicly.": "이 스페이스의 페이지가 공개 공유되지 않도록 합니다.", "Page permissions": "페이지 권한},{", "Control who can view and edit individual pages. Available with an enterprise license.": "개별 페이지의 조회 및 편집 권한을 제어합니다. 엔터프라이즈 라이선스에서 이용 가능합니다.", - "Enable public sharing": "공유 활성화", - "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공유를 활성화하시겠습니까? 멤버들이 페이지를 공개적으로 공유할 수 있게 됩니다.", - "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 워크스페이스의 모든 기존 공유 링크가 삭제됩니다.", - "Are you sure you want to enable public sharing for this space?": "이 공간의 공유를 활성화하시겠습니까?", - "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "정말로 공유를 비활성화하시겠습니까? 이 공간의 모든 기존 공유 링크가 삭제됩니다.", - "Public sharing is disabled": "공유가 비활성화되었습니다.", - "Public sharing has been disabled at the workspace level.": "워크스페이스 수준에서 공유가 비활성화되었습니다.", - "Public sharing has been disabled for this space.": "이 공간의 공유가 비활성화되었습니다.", + "Enable public sharing": "공개 공유 활성화", + "Are you sure you want to enable public sharing? Members will be able to share pages publicly.": "공개 공유를 활성화하시겠습니까? 멤버가 페이지를 공개적으로 공유할 수 있게 됩니다.", + "Are you sure you want to disable public sharing? All existing shared links in this workspace will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 워크스페이스의 기존 공유 링크가 모두 삭제됩니다.", + "Are you sure you want to enable public sharing for this space?": "이 스페이스에 대해 공개 공유를 활성화하시겠습니까?", + "Are you sure you want to disable public sharing? All existing shared links in this space will be deleted.": "공개 공유를 비활성화하시겠습니까? 이 스페이스의 기존 공유 링크가 모두 삭제됩니다.", + "Public sharing is disabled": "공개 공유가 비활성화됨", + "Public sharing has been disabled at the workspace level.": "공개 공유가 워크스페이스 수준에서 비활성화되었습니다.", + "Public sharing has been disabled for this space.": "이 스페이스의 공개 공유가 비활성화되었습니다.", "Copy page": "페이지 복사", "Copy page to a different space.": "다른 공간으로 페이지 복사하기.", - "Page copied successfully": "페이지가 성공적으로 복사되었습니다", - "Page duplicated successfully": "페이지가 성공적으로 복제되었습니다", + "Page copied successfully": "페이지 복사됨", + "Page duplicated successfully": "페이지 복제됨", "Find": "찾기", "Not found": "찾을 수 없음", "Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)", @@ -539,13 +542,13 @@ "Error": "오류", "Failed to disable MFA": "MFA 비활성화에 실패했습니다", "Disable two-factor authentication": "2단계 인증 비활성화", - "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "이중 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.", - "Please enter your password to disable two-factor authentication:": "이중 인증 비활성화를 위해 비밀번호를 입력하세요:", - "Two-factor authentication has been enabled": "2단계 인증이 활성화되었습니다", - "Two-factor authentication has been disabled": "2단계 인증이 비활성화되었습니다", + "Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "2단계 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.", + "Please enter your password to disable two-factor authentication:": "2단계 인증 비활성화를 위해 비밀번호를 입력하세요:", + "Two-factor authentication has been enabled": "2단계 인증이 활성화됨", + "Two-factor authentication has been disabled": "2단계 인증이 비활성화됨", "2-step verification": "2단계 인증", "Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.", - "Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.", + "Two-factor authentication is active on your account.": "2단계 인증이 계정에 활성화되어 있습니다.", "Add 2FA method": "2FA 방법 추가", "Backup codes": "백업 코드", "Disable": "비활성화", @@ -575,13 +578,13 @@ "Save your backup codes": "백업 코드를 저장하세요", "These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.", "Print": "인쇄", - "Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.", - "Two-Factor authentication required": "2단계 인증이 필요합니다", + "Two-factor authentication has been set up. Please log in again.": "2단계 인증이 설정되었습니다. 다시 로그인해 주세요.", + "Two-Factor authentication required": "2단계 인증 필요", "Your workspace requires two-factor authentication for all users": "이 워크스페이스는 모든 사용자에게 2단계 인증을 요구합니다", - "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.", + "To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스에 계속 액세스하려면 2단계 인증을 설정해야 합니다. 이는 계정의 보안을 한층 강화합니다.", "Set up two-factor authentication": "2단계 인증 설정", "Cancel and logout": "취소하고 로그아웃", - "Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.", + "Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 2단계 인증이 필요합니다. 계속하려면 설정해 주세요.", "This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.", "Password is required": "비밀번호는 필수입니다", "Password must be at least 8 characters": "비밀번호는 8자 이상이어야 합니다", @@ -590,10 +593,10 @@ "Enter the 6-digit code found in your authenticator app": "인증 앱에 표시된 6자리 코드를 입력하세요", "Need help authenticating?": "인증에 도움이 필요하십니까?", "MFA QR Code": "MFA QR 코드", - "Account created successfully. Please log in to set up two-factor authentication.": "계정이 성공적으로 생성되었습니다. 이중 인증을 설정하려면 로그인해 주세요.", - "Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 완료하세요.", - "Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 설정하세요.", - "Password reset was successful. Please log in with your new password.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.", + "Account created successfully. Please log in to set up two-factor authentication.": "계정 생성됨. 2단계 인증을 설정하려면 로그인해 주세요.", + "Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정됨. 새 비밀번호로 로그인하여 2단계 인증을 완료하세요.", + "Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정됨. 새 비밀번호로 로그인하여 2단계 인증을 설정하세요.", + "Password reset was successful. Please log in with your new password.": "비밀번호 재설정됨. 새 비밀번호로 로그인하세요.", "Two-factor authentication": "2단계 인증", "Use authenticator app instead": "대신 인증 앱 사용", "Verify backup code": "백업 코드 확인", @@ -615,7 +618,7 @@ "Permanently delete": "영구 삭제", "{{name}} moved this page to Trash {{time}}.": "{{name}}님이 {{time}}에 이 페이지를 휴지통으로 이동했습니다.", "Page moved to trash": "페이지가 휴지통으로 이동되었습니다", - "Page restored successfully": "페이지가 성공적으로 복원되었습니다", + "Page restored successfully": "페이지 복원됨", "Deleted by": "삭제한 사람", "Deleted at": "삭제 시간", "Preview": "미리보기", @@ -638,7 +641,7 @@ "Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.", "Enter valid domain names separated by comma or space": "쉼표 또는 공백으로 구분된 유효한 도메인 이름을 입력하세요", "Enforce two-factor authentication": "2단계 인증 강제", - "Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 작업 공간에 액세스하기 위해 이중 인증을 활성화해야 합니다.", + "Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 워크스페이스에 액세스하기 위해 2단계 인증을 활성화해야 합니다.", "Toggle MFA enforcement": "MFA 강제 설정 전환", "Display name": "표시 이름", "Allow signup": "가입 허용", @@ -657,7 +660,7 @@ "Remove image": "이미지 제거", "Failed to remove image": "이미지 제거 실패", "Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.", - "Image removed successfully": "이미지가 성공적으로 제거되었습니다", + "Image removed successfully": "이미지 삭제됨", "API key": "API 키", "API keys": "API 키", "API management": "API 관리", @@ -669,7 +672,7 @@ "Last use": "최근 사용", "No API keys found": "API 키를 찾을 수 없습니다", "No expiration": "유효기간 없음", - "Revoked successfully": "성공적으로 취소되었습니다", + "Revoked successfully": "폐기 완료", "Select expiration date": "만료일 선택", "This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.", "Update": "업데이트", @@ -696,7 +699,7 @@ "Upgrade your plan": "요금제를 업그레이드하세요", "Available with a paid license": "유료 라이선스에서만 사용 가능합니다", "Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI는 Docmost 유료 에디션에서 제공됩니다. sales@docmost.com으로 문의하세요.", "AI & MCP": "AI 및 MCP", "AI": "AI", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "AI 어시스턴트와 도구가 워크스페이스 콘텐츠와 상호작용할 수 있도록 MCP 서버를 활성화하세요.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP는 Docmost 엔터프라이즈 에디션에서만 제공됩니다. sales@docmost.com으로 문의하세요.", "MCP Server URL": "MCP 서버 URL", - "Use your API key for authentication. You can manage API keys in your account settings.": "인증을 위해 API 키를 사용하세요. API 키는 계정 설정에서 관리할 수 있습니다.", + "Connect AI assistants with your Docmost account via OAuth.": "OAuth를 통해 AI 도우미를 Docmost 계정에 연결합니다.", + "Enforce OAuth": "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 강제 전환", "Supported tools": "지원되는 도구", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "워크스페이스에 MCP가 활성화되어 있습니다. AI 어시스턴트를 연결하려면 API 키를 사용하세요.", "MCP server URL:": "MCP 서버 URL:", "Learn more": "자세히 알아보기", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "워크스페이스의 모든 사용자를 위한 API 키를 관리하세요. 사용 방법은 API 문서를 참고하세요.", @@ -939,8 +944,8 @@ "Try again": "다시 시도", "Untitled chat": "제목 없는 채팅", "What can I help you with?": "무엇을 도와드릴까요?", - "Are you sure you want to revoke this {{credential}}": "이 {{credential}}을 취소하시겠습니까?", - "Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자에서 사용자와 그룹을 자동으로 프로비저닝합니다.", + "Are you sure you want to revoke this {{credential}}": "이 {{credential}}을(를) 폐기하시겠습니까?", + "Automatically provision users and groups from your identity provider via SCIM.": "SCIM을 통해 ID 공급자로부터 사용자와 그룹을 자동으로 프로비저닝합니다.", "Configure your identity provider with this URL to provision users and groups.": "사용자와 그룹을 프로비저닝할 수 있도록 이 URL로 ID 공급자를 구성하세요.", "Create {{credential}}": "{{credential}} 만들기", "{{credential}} created": "{{credential}} 생성됨", @@ -953,7 +958,7 @@ "Important": "중요", "Make sure to copy your {{credential}} now. You won't be able to see it again!": "지금 {{credential}}를 복사해 두세요. 다시는 볼 수 없습니다!", "Never": "안 함", - "Revoke {{credential}}": "{{credential}} 취소", + "Revoke {{credential}}": "{{credential}} 폐기", "SCIM endpoint URL": "SCIM 엔드포인트 URL", "SCIM provisioning": "SCIM 프로비저닝", "SCIM takes precedence over SSO group sync while enabled.": "SCIM이 활성화되어 있는 동안에는 SSO 그룹 동기화보다 SCIM이 우선 적용됩니다.", @@ -1186,8 +1191,8 @@ "Default value": "기본값", "Delete property": "속성 삭제", "Delete view": "보기 삭제", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "1개 행을 삭제하시겠습니까?", + "Delete {{count}} rows?_other": "{{count}}개 행을 삭제하시겠습니까?", "Descending": "내림차순", "Discard": "삭제", "Doesn't contain": "포함하지 않음", @@ -1286,8 +1291,51 @@ "Value": "값", "View updated for everyone": "보기가 모두에게 업데이트되었습니다", "You have unsaved changes. Do you want to discard them?": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} rows deleted_one": "1개 행 삭제 완료", + "{{count}} rows deleted_other": "{{count}}개 행 삭제 완료", + "{{count}} selected_one": "1개 선택됨", + "{{count}} selected_other": "{{count}}개 선택됨", + "Compare": "비교", + "Compare versions": "버전 비교", + "Select version from {{date}}": "{{date}}의 버전 선택", + "Version actions for {{date}}": "{{date}} 버전 작업", + "Comparing {{newer}} and {{older}}": "{{newer}} 및 {{older}} 비교 중", + "Exit compare": "비교 종료", + "Search attachments...": "첨부파일 검색...", + "Error loading attachments.": "첨부파일을 불러오는 중 오류가 발생했습니다.", + "No attachments on this page yet.": "이 페이지에는 아직 첨부파일이 없습니다.", + "Uploaded by {{name}}": "업로드한 사람: {{name}}", + "Download {{name}}": "{{name}} 다운로드", + "Access revoked": "액세스가 취소되었습니다", + "Authorize application": "애플리케이션 승인", + "{{name}} wants to access {{workspace}}": "{{name}}에서 {{workspace}}에 액세스하려고 합니다", + "Not you? Switch account": "본인이 아니신가요? 계정을 전환하세요", + "This application will be able to:": "이 애플리케이션이 수행할 수 있는 작업:", + "Write": "쓰기", + "Invalid authorization request": "잘못된 승인 요청", + "Authorize": "승인", + "Application": "애플리케이션", + "Permissions": "권한", + "Authorized": "승인됨", + "Revoke access": "액세스 취소", + "Revoke access for {{name}}": "{{name}}의 액세스 취소", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "정말로 {{name}}의 액세스를 취소하시겠습니까? 이 애플리케이션은 더 이상 회원님의 계정에 액세스할 수 없습니다.", + "Something went wrong. Please try again.": "문제가 발생했습니다. 다시 시도해 주세요.", + "Remove {{name}}": "{{name}} 제거", + "Make sure you trust this application before authorizing it.": "이 애플리케이션을 승인하기 전에 신뢰할 수 있는지 확인하세요.", + "You will be redirected to": "다음으로 리디렉션됩니다", + "View content without making changes.": "변경하지 않고 콘텐츠를 봅니다.", + "Create and modify content.": "콘텐츠를 생성하고 수정합니다.", + "Applications and AI assistants you have authorized to access your account.": "회원님의 계정에 액세스하도록 승인한 애플리케이션 및 AI 도우미입니다.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "워크스페이스에서 MCP가 활성화되어 있습니다. OAuth를 통해 AI 도우미를 Docmost 계정에 연결하세요.", + "Authorized apps": "승인된 앱", + "No authorized apps yet.": "아직 승인된 앱이 없습니다.", + "Workspace knowledge only": "워크스페이스 지식만 사용", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "AI 채팅이 워크스페이스 페이지와 업로드된 파일의 내용만 바탕으로 답변하도록 제한합니다. 외부 지식은 사용하지 않습니다.", + "Toggle workspace knowledge only": "워크스페이스 지식만 사용 전환", + "Read-only mode": "읽기 전용 모드", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI 채팅은 워크스페이스 콘텐츠를 검색하고 읽을 수 있지만, 페이지를 생성하거나 편집할 수는 없습니다.", + "Toggle AI Chat read-only mode": "AI 채팅 읽기 전용 모드 전환", + "Title only": "제목만", + "you": "회원님" } diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json index 59c1d7a73..b04f2f6fa 100644 --- a/apps/client/public/locales/nl-NL/translation.json +++ b/apps/client/public/locales/nl-NL/translation.json @@ -294,6 +294,7 @@ "Export space": "Exporteer ruimte", "Export {{type}}": "Exporteer {{type}}", "File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}", + "Media": "Media", "Align left": "Links uitlijnen", "Align right": "Rechts uitlijnen", "Align center": "Centreren", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Horizontale lijn invoegen", "Page break": "Pagina-einde", "Insert a page break for printing.": "Voeg een pagina-einde in voor het afdrukken.", + "Footnote": "Voetnoot", + "Insert a footnote reference.": "Voeg een voetnootverwijzing in.", "Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.", "Upload any video from your device.": "Upload een video vanaf uw apparaat.", "Upload any audio from your device.": "Upload een audio vanaf uw apparaat.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Upgrade je abonnement", "Available with a paid license": "Beschikbaar met een betaalde licentie", "Upgrade your license tier.": "Upgrade je licentieniveau.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI is beschikbaar in de betaalde edities van Docmost. Neem contact op met sales@docmost.com.", "AI & MCP": "AI & MCP", "AI": "AI", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Schakel de MCP-server in zodat AI-assistenten en tools kunnen interageren met de inhoud van uw werkruimte.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is alleen beschikbaar in de Docmost Enterprise-editie. Neem contact op met sales@docmost.com.", "MCP Server URL": "MCP-server-URL", - "Use your API key for authentication. You can manage API keys in your account settings.": "Gebruik uw API-sleutel voor authenticatie. U kunt API-sleutels beheren in uw accountinstellingen.", + "Connect AI assistants with your Docmost account via OAuth.": "Verbind AI-assistenten met je Docmost-account via OAuth.", + "Enforce OAuth": "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", "Supported tools": "Ondersteunde tools", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "In uw werkruimte is MCP ingeschakeld. Gebruik uw API-sleutel om AI-assistenten te koppelen.", "MCP server URL:": "MCP-server-URL:", "Learn more": "Meer informatie", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Beheer API-sleutels voor alle gebruikers in de werkruimte. Bekijk de API-documentatie voor gebruiksdetails.", @@ -1289,5 +1294,48 @@ "{{count}} rows deleted_one": "1 rij verwijderd", "{{count}} rows deleted_other": "{{count}} rijen verwijderd", "{{count}} selected_one": "1 geselecteerd", - "{{count}} selected_other": "{{count}} geselecteerd" + "{{count}} selected_other": "{{count}} geselecteerd", + "Compare": "Vergelijken", + "Compare versions": "Versies vergelijken", + "Select version from {{date}}": "Selecteer versie van {{date}}", + "Version actions for {{date}}": "Versieacties voor {{date}}", + "Comparing {{newer}} and {{older}}": "{{newer}} en {{older}} vergelijken", + "Exit compare": "Vergelijken afsluiten", + "Search attachments...": "Bijlagen zoeken...", + "Error loading attachments.": "Fout bij het laden van bijlagen.", + "No attachments on this page yet.": "Er zijn nog geen bijlagen op deze pagina.", + "Uploaded by {{name}}": "Geüpload door {{name}}", + "Download {{name}}": "{{name}} downloaden", + "Access revoked": "Toegang ingetrokken", + "Authorize application": "Applicatie autoriseren", + "{{name}} wants to access {{workspace}}": "{{name}} wil toegang tot {{workspace}}", + "Not you? Switch account": "Ben jij dit niet? Wissel van account", + "This application will be able to:": "Deze applicatie kan het volgende doen:", + "Write": "Schrijven", + "Invalid authorization request": "Ongeldig autorisatieverzoek", + "Authorize": "Autoriseren", + "Application": "Applicatie", + "Permissions": "Machtigingen", + "Authorized": "Geautoriseerd", + "Revoke access": "Toegang intrekken", + "Revoke access for {{name}}": "Toegang voor {{name}} intrekken", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Weet je zeker dat je de toegang voor {{name}} wilt intrekken? De applicatie heeft dan geen toegang meer tot je account.", + "Something went wrong. Please try again.": "Er is iets misgegaan. Probeer het opnieuw.", + "Remove {{name}}": "{{name}} verwijderen", + "Make sure you trust this application before authorizing it.": "Zorg ervoor dat je deze applicatie vertrouwt voordat je haar autoriseert.", + "You will be redirected to": "Je wordt doorgestuurd naar", + "View content without making changes.": "Inhoud bekijken zonder wijzigingen aan te brengen.", + "Create and modify content.": "Inhoud maken en wijzigen.", + "Applications and AI assistants you have authorized to access your account.": "Applicaties en AI-assistenten die je hebt geautoriseerd om toegang te krijgen tot je account.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "MCP is ingeschakeld voor je werkruimte. Verbind AI-assistenten met je Docmost-account via OAuth.", + "Authorized apps": "Geautoriseerde apps", + "No authorized apps yet.": "Nog geen geautoriseerde apps.", + "Workspace knowledge only": "Alleen werkruimtek kennis", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Beperk AI Chat tot het beantwoorden op basis van alleen je werkruimtepagina's en geüploade bestanden. Er wordt geen externe kennis gebruikt.", + "Toggle workspace knowledge only": "Alleen werkruimtekennis in-/uitschakelen", + "Read-only mode": "Alleen-lezenmodus", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat kan werkruimte-inhoud doorzoeken en lezen, maar kan geen pagina's maken of bewerken.", + "Toggle AI Chat read-only mode": "Alleen-lezenmodus voor AI Chat in-/uitschakelen", + "Title only": "Alleen titel", + "you": "jij" } diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json index 2e4cd89ef..610309d75 100644 --- a/apps/client/public/locales/pt-BR/translation.json +++ b/apps/client/public/locales/pt-BR/translation.json @@ -294,6 +294,7 @@ "Export space": "Exportar espaço", "Export {{type}}": "Exportar para {{type}}", "File exceeds the {{limit}} attachment limit": "O arquivo excede o limite de anexos {{limit}}", + "Media": "Mídia", "Align left": "Alinhar à esquerda", "Align right": "Alinhar à direita", "Align center": "Alinhar ao centro", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Insira um divisor horizontal", "Page break": "Quebra de página", "Insert a page break for printing.": "Insira uma quebra de página para impressão.", + "Footnote": "Nota de rodapé", + "Insert a footnote reference.": "Inserir uma referência de nota de rodapé.", "Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.", "Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.", "Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Faça upgrade do seu plano", "Available with a paid license": "Disponível com uma licença paga", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "A IA está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "A IA está disponível nas edições pagas do Docmost. Contate sales@docmost.com.", "AI & MCP": "IA e MCP", "AI": "IA", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Ative o servidor MCP para permitir que assistentes de IA e ferramentas interajam com o conteúdo do seu espaço de trabalho.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "O MCP está disponível apenas na edição empresarial do Docmost. Contate sales@docmost.com.", "MCP Server URL": "URL do servidor MCP", - "Use your API key for authentication. You can manage API keys in your account settings.": "Use sua chave de API para autenticação. Você pode gerenciar chaves de API nas configurações da sua conta.", + "Connect AI assistants with your Docmost account via OAuth.": "Conecte assistentes de IA à sua conta do Docmost via OAuth.", + "Enforce OAuth": "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", "Supported tools": "Ferramentas compatíveis", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Seu espaço de trabalho tem MCP habilitado. Use sua chave de API para conectar assistentes de IA.", "MCP server URL:": "URL do servidor MCP:", "Learn more": "Saiba mais", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Gerencie as chaves de API de todos os usuários do workspace. Veja a documentação da API para detalhes de uso.", @@ -1289,5 +1294,48 @@ "{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} selected_other": "{{count}} selected", + "Compare": "Comparar", + "Compare versions": "Comparar versões", + "Select version from {{date}}": "Selecionar versão de {{date}}", + "Version actions for {{date}}": "Ações da versão de {{date}}", + "Comparing {{newer}} and {{older}}": "Comparando {{newer}} e {{older}}", + "Exit compare": "Sair da comparação", + "Search attachments...": "Pesquisar anexos...", + "Error loading attachments.": "Erro ao carregar anexos.", + "No attachments on this page yet.": "Ainda não há anexos nesta página.", + "Uploaded by {{name}}": "Enviado por {{name}}", + "Download {{name}}": "Baixar {{name}}", + "Access revoked": "Acesso revogado", + "Authorize application": "Autorizar aplicativo", + "{{name}} wants to access {{workspace}}": "{{name}} quer acessar {{workspace}}", + "Not you? Switch account": "Não é você? Trocar conta", + "This application will be able to:": "Este aplicativo poderá:", + "Write": "Escrever", + "Invalid authorization request": "Solicitação de autorização inválida", + "Authorize": "Autorizar", + "Application": "Aplicativo", + "Permissions": "Permissões", + "Authorized": "Autorizado", + "Revoke access": "Revogar acesso", + "Revoke access for {{name}}": "Revogar acesso de {{name}}", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Tem certeza de que deseja revogar o acesso de {{name}}? O aplicativo não poderá mais acessar sua conta.", + "Something went wrong. Please try again.": "Algo deu errado. Tente novamente.", + "Remove {{name}}": "Remover {{name}}", + "Make sure you trust this application before authorizing it.": "Certifique-se de confiar neste aplicativo antes de autorizá-lo.", + "You will be redirected to": "Você será redirecionado para", + "View content without making changes.": "Visualizar conteúdo sem fazer alterações.", + "Create and modify content.": "Criar e modificar conteúdo.", + "Applications and AI assistants you have authorized to access your account.": "Aplicativos e assistentes de IA que você autorizou a acessar sua conta.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Seu workspace tem o MCP ativado. Conecte assistentes de IA à sua conta do Docmost via OAuth.", + "Authorized apps": "Aplicativos autorizados", + "No authorized apps yet.": "Ainda não há aplicativos autorizados.", + "Workspace knowledge only": "Somente conhecimento do workspace", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrinja o AI Chat para responder apenas com base nas páginas do seu workspace e nos arquivos enviados. Ele não usará conhecimento externo.", + "Toggle workspace knowledge only": "Alternar somente conhecimento do workspace", + "Read-only mode": "Modo somente leitura", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "O AI Chat pode pesquisar e ler o conteúdo do workspace, mas não pode criar nem editar páginas.", + "Toggle AI Chat read-only mode": "Alternar modo somente leitura do AI Chat", + "Title only": "Somente título", + "you": "você" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index bae44d133..a49ee5d6c 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -294,6 +294,7 @@ "Export space": "Экспорт пространства", "Export {{type}}": "Экспорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл превышает лимит вложений {{limit}}", + "Media": "Медиа", "Align left": "По левому краю", "Align right": "По правому краю", "Align center": "По центру", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Вставить горизонтальный разделитель", "Page break": "Разрыв страницы", "Insert a page break for printing.": "Вставить разрыв страницы для печати.", + "Footnote": "Сноска", + "Insert a footnote reference.": "Вставить ссылку на сноску.", "Upload any image from your device.": "Загрузить любое изображение с вашего устройства.", "Upload any video from your device.": "Загрузить любое видео с вашего устройства.", "Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Обновите свой тарифный план", "Available with a paid license": "Доступно с платной лицензией", "Upgrade your license tier.": "Обновите уровень вашей лицензии.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ИИ доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ИИ доступен в платных версиях Docmost. Свяжитесь по адресу sales@docmost.com.", "AI & MCP": "ИИ и MCP", "AI": "ИИ", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Включите сервер MCP, чтобы ИИ-ассистенты и инструменты могли взаимодействовать с содержимым вашего рабочего пространства.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступен только в корпоративной версии Docmost. Свяжитесь по адресу sales@docmost.com.", "MCP Server URL": "URL сервера MCP", - "Use your API key for authentication. You can manage API keys in your account settings.": "Используйте ваш API-ключ для аутентификации. Управлять API-ключами можно в настройках аккаунта.", + "Connect AI assistants with your Docmost account via OAuth.": "Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.", + "Enforce OAuth": "Сделать 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", "Supported tools": "Поддерживаемые инструменты", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "В вашем рабочем пространстве включён MCP. Используйте свой API-ключ для подключения ИИ-ассистентов.", "MCP server URL:": "URL сервера MCP:", "Learn more": "Подробнее", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите документацию по API для получения информации об использовании.", @@ -1186,8 +1191,8 @@ "Default value": "Значение по умолчанию", "Delete property": "Удалить свойство", "Delete view": "Удалить представление", - "Delete {{count}} rows?_one": "Delete 1 row?", - "Delete {{count}} rows?_other": "Delete {{count}} rows?", + "Delete {{count}} rows?_one": "Удалить 1 строку?", + "Delete {{count}} rows?_other": "Удалить {{count}} строк?", "Descending": "По убыванию", "Discard": "Отменить", "Doesn't contain": "Не содержит", @@ -1286,8 +1291,51 @@ "Value": "Значение", "View updated for everyone": "Представление обновлено для всех", "You have unsaved changes. Do you want to discard them?": "У вас есть несохранённые изменения. Хотите их отменить?", - "{{count}} rows deleted_one": "1 row deleted", - "{{count}} rows deleted_other": "{{count}} rows deleted", - "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} rows deleted_one": "1 строка удалена", + "{{count}} rows deleted_other": "{{count}} строк удалено", + "{{count}} selected_one": "1 выбрано", + "{{count}} selected_other": "{{count}} выбрано", + "Compare": "Сравнить", + "Compare versions": "Сравнить версии", + "Select version from {{date}}": "Выбрать версию от {{date}}", + "Version actions for {{date}}": "Действия с версией от {{date}}", + "Comparing {{newer}} and {{older}}": "Сравнение {{newer}} и {{older}}", + "Exit compare": "Выйти из режима сравнения", + "Search attachments...": "Поиск вложений...", + "Error loading attachments.": "Ошибка при загрузке вложений.", + "No attachments on this page yet.": "На этой странице пока нет вложений.", + "Uploaded by {{name}}": "Загружено пользователем {{name}}", + "Download {{name}}": "Скачать {{name}}", + "Access revoked": "Доступ отозван", + "Authorize application": "Авторизовать приложение", + "{{name}} wants to access {{workspace}}": "{{name}} запрашивает доступ к {{workspace}}", + "Not you? Switch account": "Не вы? Сменить аккаунт", + "This application will be able to:": "Это приложение сможет:", + "Write": "Запись", + "Invalid authorization request": "Недопустимый запрос на авторизацию", + "Authorize": "Авторизовать", + "Application": "Приложение", + "Permissions": "Разрешения", + "Authorized": "Авторизовано", + "Revoke access": "Отозвать доступ", + "Revoke access for {{name}}": "Отозвать доступ для {{name}}", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Вы уверены, что хотите отозвать доступ для {{name}}? Приложение больше не сможет получить доступ к вашей учетной записи.", + "Something went wrong. Please try again.": "Что-то пошло не так. Пожалуйста, попробуйте еще раз.", + "Remove {{name}}": "Удалить {{name}}", + "Make sure you trust this application before authorizing it.": "Прежде чем авторизовать это приложение, убедитесь, что вы ему доверяете.", + "You will be redirected to": "Вы будете перенаправлены на", + "View content without making changes.": "Просматривать содержимое без внесения изменений.", + "Create and modify content.": "Создавать и изменять содержимое.", + "Applications and AI assistants you have authorized to access your account.": "Приложения и AI-помощники, которым вы разрешили доступ к своей учетной записи.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "В вашем рабочем пространстве включен MCP. Подключайте AI-помощников к вашей учетной записи Docmost через OAuth.", + "Authorized apps": "Авторизованные приложения", + "No authorized apps yet.": "Пока нет авторизованных приложений.", + "Workspace knowledge only": "Только знания рабочего пространства", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Ограничить AI Chat ответами только на основе страниц вашего рабочего пространства и загруженных файлов. Внешние знания использоваться не будут.", + "Toggle workspace knowledge only": "Переключить режим \"Только знания рабочего пространства\"", + "Read-only mode": "Режим только для чтения", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat может искать и читать содержимое рабочего пространства, но не может создавать или редактировать страницы.", + "Toggle AI Chat read-only mode": "Переключить режим только для чтения для AI Chat", + "Title only": "Только заголовок", + "you": "вы" } diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json index d3dd687ea..33b646712 100644 --- a/apps/client/public/locales/uk-UA/translation.json +++ b/apps/client/public/locales/uk-UA/translation.json @@ -294,6 +294,7 @@ "Export space": "Експорт простору", "Export {{type}}": "Експорт {{type}}", "File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}", + "Media": "Медіа", "Align left": "По лівому краю", "Align right": "По правому краю", "Align center": "По центру", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "Вставити горизонтальний роздільник", "Page break": "Розрив сторінки", "Insert a page break for printing.": "Вставте розрив сторінки для друку.", + "Footnote": "Виноска", + "Insert a footnote reference.": "Вставити посилання на виноску.", "Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.", "Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.", "Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.", @@ -696,7 +699,7 @@ "Upgrade your plan": "Оновіть свій тарифний план", "Available with a paid license": "Доступно за платною ліцензією", "Upgrade your license tier.": "Оновіть рівень своєї ліцензії.", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "ШІ доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "ШІ доступний у платних редакціях Docmost. Зверніться до sales@docmost.com.", "AI & MCP": "ШІ та MCP", "AI": "ШІ", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Увімкніть MCP‑сервер, щоб дозволити ШІ‑помічникам та інструментам взаємодіяти з вмістом вашого робочого простору.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP доступний лише в корпоративній редакції Docmost. Зверніться до sales@docmost.com.", "MCP Server URL": "URL сервера MCP", - "Use your API key for authentication. You can manage API keys in your account settings.": "Використовуйте свій API‑ключ для аутентифікації. Ви можете керувати API‑ключами в налаштуваннях облікового запису.", + "Connect AI assistants with your Docmost account via OAuth.": "Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.", + "Enforce OAuth": "Зробити 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", "Supported tools": "Підтримувані інструменти", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "У вашому робочому просторі MCP увімкнено. Використайте свій API‑ключ, щоб підключити ШІ‑помічників.", "MCP server URL:": "URL сервера MCP:", "Learn more": "Дізнатися більше", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "Керуйте ключами API для всіх користувачів у робочому просторі. Перегляньте документацію API для деталей використання.", @@ -1289,5 +1294,48 @@ "{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} selected_other": "{{count}} selected", + "Compare": "Порівняти", + "Compare versions": "Порівняти версії", + "Select version from {{date}}": "Виберіть версію від {{date}}", + "Version actions for {{date}}": "Дії з версією від {{date}}", + "Comparing {{newer}} and {{older}}": "Порівняння {{newer}} і {{older}}", + "Exit compare": "Вийти з режиму порівняння", + "Search attachments...": "Пошук вкладень...", + "Error loading attachments.": "Помилка завантаження вкладень.", + "No attachments on this page yet.": "На цій сторінці ще немає вкладень.", + "Uploaded by {{name}}": "Завантажено користувачем {{name}}", + "Download {{name}}": "Завантажити {{name}}", + "Access revoked": "Доступ відкликано", + "Authorize application": "Авторизувати застосунок", + "{{name}} wants to access {{workspace}}": "{{name}} хоче отримати доступ до {{workspace}}", + "Not you? Switch account": "Не ви? Змінити обліковий запис", + "This application will be able to:": "Цей застосунок зможе:", + "Write": "Запис", + "Invalid authorization request": "Недійсний запит на авторизацію", + "Authorize": "Авторизувати", + "Application": "Застосунок", + "Permissions": "Дозволи", + "Authorized": "Авторизовано", + "Revoke access": "Відкликати доступ", + "Revoke access for {{name}}": "Відкликати доступ для {{name}}", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Ви впевнені, що хочете відкликати доступ для {{name}}? Застосунок більше не зможе отримувати доступ до вашого облікового запису.", + "Something went wrong. Please try again.": "Щось пішло не так. Спробуйте ще раз.", + "Remove {{name}}": "Видалити {{name}}", + "Make sure you trust this application before authorizing it.": "Перш ніж авторизувати цей застосунок, переконайтеся, що ви йому довіряєте.", + "You will be redirected to": "Вас буде перенаправлено на", + "View content without making changes.": "Переглядати вміст без внесення змін.", + "Create and modify content.": "Створювати та змінювати вміст.", + "Applications and AI assistants you have authorized to access your account.": "Застосунки та AI-асистенти, яким ви надали дозвіл на доступ до свого облікового запису.", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "У вашому робочому просторі ввімкнено MCP. Підключайте AI-асистентів до свого облікового запису Docmost через OAuth.", + "Authorized apps": "Авторизовані застосунки", + "No authorized apps yet.": "Авторизованих застосунків ще немає.", + "Workspace knowledge only": "Лише знання робочого простору", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Обмежити AI Chat відповідями лише на основі сторінок вашого робочого простору та завантажених файлів. Зовнішні знання не використовуватимуться.", + "Toggle workspace knowledge only": "Перемкнути режим лише знань робочого простору", + "Read-only mode": "Режим лише читання", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI Chat може шукати та читати вміст робочого простору, але не може створювати або редагувати сторінки.", + "Toggle AI Chat read-only mode": "Перемкнути режим лише читання для AI Chat", + "Title only": "Лише заголовок", + "you": "ви" } diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json index 97ff9ce85..68467a024 100644 --- a/apps/client/public/locales/zh-CN/translation.json +++ b/apps/client/public/locales/zh-CN/translation.json @@ -294,6 +294,7 @@ "Export space": "导出空间", "Export {{type}}": "导出为 {{type}}", "File exceeds the {{limit}} attachment limit": "文件超出了 {{limit}} 类型附件限制", + "Media": "媒体", "Align left": "靠左对齐", "Align right": "靠右对齐", "Align center": "居中对齐", @@ -387,6 +388,8 @@ "Insert horizontal rule divider": "插入水平分割线", "Page break": "分页符", "Insert a page break for printing.": "插入一个用于打印的分页符。", + "Footnote": "脚注", + "Insert a footnote reference.": "插入脚注引用。", "Upload any image from your device.": "从设备上传任何图像", "Upload any video from your device.": "从设备上传任何视频", "Upload any audio from your device.": "从您的设备上传任意音频文件。", @@ -696,7 +699,7 @@ "Upgrade your plan": "升级您的方案", "Available with a paid license": "需付费许可才可用", "Upgrade your license tier.": "升级您的许可等级。", - "AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "AI 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。", + "AI is available in the Docmost paid editions. Contact sales@docmost.com.": "AI 在 Docmost 付费版中提供。请联系 sales@docmost.com。", "AI & MCP": "AI 与 MCP", "AI": "AI", "MCP": "MCP", @@ -704,9 +707,11 @@ "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "启用 MCP 服务器以允许 AI 助手和工具与您的工作区内容交互。", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP 仅在 Docmost 企业版中提供。请联系 sales@docmost.com。", "MCP Server URL": "MCP 服务器 URL", - "Use your API key for authentication. You can manage API keys in your account settings.": "使用您的 API 密钥进行身份验证。您可以在账户设置中管理 API 密钥。", + "Connect AI assistants with your Docmost account via OAuth.": "通过 OAuth 将 AI 助手连接到你的 Docmost 账户。", + "Enforce OAuth": "强制使用 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 设置", "Supported tools": "支持的工具", - "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "您的工作区已启用 MCP。使用您的 API 密钥连接 AI 助手。", "MCP server URL:": "MCP 服务器 URL:", "Learn more": "了解更多", "Manage API keys for all users in the workspace. View the API documentation for usage details.": "为工作区内所有用户管理 API 密钥。有关使用详情,请查阅API 文档。", @@ -1289,5 +1294,48 @@ "{{count}} rows deleted_one": "已删除 1 行", "{{count}} rows deleted_other": "已删除 {{count}} 行", "{{count}} selected_one": "已选择 1 项", - "{{count}} selected_other": "已选择 {{count}} 项" + "{{count}} selected_other": "已选择 {{count}} 项", + "Compare": "比较", + "Compare versions": "比较版本", + "Select version from {{date}}": "选择 {{date}} 的版本", + "Version actions for {{date}}": "{{date}} 的版本操作", + "Comparing {{newer}} and {{older}}": "正在比较 {{newer}} 和 {{older}}", + "Exit compare": "退出比较", + "Search attachments...": "搜索附件……", + "Error loading attachments.": "加载附件时出错。", + "No attachments on this page yet.": "此页面上还没有附件。", + "Uploaded by {{name}}": "由 {{name}} 上传", + "Download {{name}}": "下载 {{name}}", + "Access revoked": "访问权限已撤销", + "Authorize application": "授权应用程序", + "{{name}} wants to access {{workspace}}": "{{name}} 想要访问 {{workspace}}", + "Not you? Switch account": "不是你?切换账户", + "This application will be able to:": "此应用程序将能够:", + "Write": "写入", + "Invalid authorization request": "无效的授权请求", + "Authorize": "授权", + "Application": "应用程序", + "Permissions": "权限", + "Authorized": "已授权", + "Revoke access": "撤销访问权限", + "Revoke access for {{name}}": "撤销 {{name}} 的访问权限", + "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "你确定要撤销 {{name}} 的访问权限吗?该应用程序将无法再访问你的账户。", + "Something went wrong. Please try again.": "出了点问题。请重试。", + "Remove {{name}}": "移除 {{name}}", + "Make sure you trust this application before authorizing it.": "在授权之前,请确保你信任此应用程序。", + "You will be redirected to": "你将被重定向到", + "View content without making changes.": "查看内容而不进行更改。", + "Create and modify content.": "创建和修改内容。", + "Applications and AI assistants you have authorized to access your account.": "你已授权访问你账户的应用程序和 AI 助手。", + "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "你的工作区已启用 MCP。通过 OAuth 将 AI 助手连接到你的 Docmost 账户。", + "Authorized apps": "已授权的应用", + "No authorized apps yet.": "尚无已授权的应用。", + "Workspace knowledge only": "仅限工作区知识", + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "将 AI 聊天限制为仅根据你的工作区页面和已上传文件进行回答。它不会使用外部知识。", + "Toggle workspace knowledge only": "切换“仅限工作区知识”", + "Read-only mode": "只读模式", + "AI Chat can search and read workspace content, but cannot create or edit pages.": "AI 聊天可以搜索和读取工作区内容,但不能创建或编辑页面。", + "Toggle AI Chat read-only mode": "切换 AI 聊天只读模式", + "Title only": "仅标题", + "you": "你" } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index ab291ffea..e4238a0a6 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,60 +1,103 @@ +import { lazy, Suspense, useEffect } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; -import SetupWorkspace from "@/pages/auth/setup-workspace.tsx"; -import LoginPage from "@/pages/auth/login"; -import Home from "@/pages/dashboard/home"; -import Page from "@/pages/page/page"; -import AccountSettings from "@/pages/settings/account/account-settings"; -import WorkspaceMembers from "@/pages/settings/workspace/workspace-members"; -import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings"; -import Groups from "@/pages/settings/group/groups"; -import GroupInfo from "./pages/settings/group/group-info"; -import Spaces from "@/pages/settings/space/spaces.tsx"; -import { Error404 } from "@/components/ui/error-404.tsx"; -import AccountPreferences from "@/pages/settings/account/account-preferences.tsx"; -import SpaceHome from "@/pages/space/space-home.tsx"; -import PageRedirect from "@/pages/page/page-redirect.tsx"; import Layout from "@/components/layouts/global/layout.tsx"; -import InviteSignup from "@/pages/auth/invite-signup.tsx"; -import ForgotPassword from "@/pages/auth/forgot-password.tsx"; -import PasswordReset from "./pages/auth/password-reset"; -import Billing from "@/ee/billing/pages/billing.tsx"; -import CloudLogin from "@/ee/pages/cloud-login.tsx"; -import CreateWorkspace from "@/ee/pages/create-workspace.tsx"; +import { Error404 } from "@/components/ui/error-404.tsx"; import { isCloud } from "@/lib/config.ts"; import { useTranslation } from "react-i18next"; -import Security from "@/ee/security/pages/security.tsx"; -import License from "@/ee/licence/pages/license.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; -import SharedPage from "@/pages/share/shared-page.tsx"; -import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx"; -import Shares from "@/pages/settings/shares/shares.tsx"; -import ShareLayout from "@/features/share/components/share-layout.tsx"; -import ShareRedirect from "@/pages/share/share-redirect.tsx"; import { useTrackOrigin } from "@/hooks/use-track-origin"; -import SpacesPage from "@/pages/spaces/spaces.tsx"; -import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page"; -import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page"; -import SpaceTrash from "@/pages/space/space-trash.tsx"; -import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; -import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; -import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; -import BasePage from "@/ee/base/pages/base-page.tsx"; -import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; -import VerifiedPages from "@/ee/page-verification/pages/verified-pages.tsx"; -import TemplateList from "@/ee/template/pages/template-list"; -import TemplateEditor from "@/ee/template/pages/template-editor"; -import FavoritesPage from "@/pages/favorites/favorites-page"; -import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; -import VerifyEmail from "@/ee/pages/verify-email.tsx"; -import LabelPage from "@/pages/label/label-page"; + + +const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx")); +const LoginPage = lazy(() => import("@/pages/auth/login")); +const Home = lazy(() => import("@/pages/dashboard/home")); +const Page = lazy(() => import("@/pages/page/page")); +const AccountSettings = lazy( + () => import("@/pages/settings/account/account-settings"), +); +const WorkspaceMembers = lazy( + () => import("@/pages/settings/workspace/workspace-members"), +); +const WorkspaceSettings = lazy( + () => import("@/pages/settings/workspace/workspace-settings"), +); +const Groups = lazy(() => import("@/pages/settings/group/groups")); +const GroupInfo = lazy(() => import("./pages/settings/group/group-info")); +const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx")); +const AccountPreferences = lazy( + () => import("@/pages/settings/account/account-preferences.tsx"), +); +const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx")); +const PageRedirect = lazy(() => import("@/pages/page/page-redirect.tsx")); +const InviteSignup = lazy(() => import("@/pages/auth/invite-signup.tsx")); +const ForgotPassword = lazy(() => import("@/pages/auth/forgot-password.tsx")); +const PasswordReset = lazy(() => import("./pages/auth/password-reset")); +const Billing = lazy(() => import("@/ee/billing/pages/billing.tsx")); +const CloudLogin = lazy(() => import("@/ee/pages/cloud-login.tsx")); +const CreateWorkspace = lazy(() => import("@/ee/pages/create-workspace.tsx")); +const Security = lazy(() => import("@/ee/security/pages/security.tsx")); +const License = lazy(() => import("@/ee/licence/pages/license.tsx")); +const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx")); +const PdfRenderPage = lazy(() => import("@/ee/pdf-export/pdf-render-page.tsx")); +const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx")); +const ShareLayout = lazy( + () => import("@/features/share/components/share-layout.tsx"), +); +const ShareRedirect = lazy(() => import("@/pages/share/share-redirect.tsx")); +const PublicSpacePage = lazy( + () => import("@/pages/public-space/public-space-page.tsx"), +); +const PublicSpaceLayout = lazy( + () => import("@/features/public-space/components/public-space-layout.tsx"), +); +const PublicSpaceDirectoryPage = lazy( + () => import("@/pages/public-space/public-space-directory-page.tsx"), +); +const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx")); +const MfaChallengePage = lazy(() => + import("@/ee/mfa/pages/mfa-challenge-page").then((m) => ({ + default: m.MfaChallengePage, + })), +); +const MfaSetupRequiredPage = lazy(() => + import("@/ee/mfa/pages/mfa-setup-required-page").then((m) => ({ + default: m.MfaSetupRequiredPage, + })), +); +const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx")); +const UserApiKeys = lazy(() => import("@/ee/api-key/pages/user-api-keys")); +const WorkspaceApiKeys = lazy( + () => import("@/ee/api-key/pages/workspace-api-keys"), +); +const AiSettings = lazy(() => import("@/ee/ai/pages/ai-settings.tsx")); +const BasePage = lazy(() => import("@/ee/base/pages/base-page.tsx")); +const AuditLogs = lazy(() => import("@/ee/audit/pages/audit-logs.tsx")); +const VerifiedPages = lazy( + () => import("@/ee/page-verification/pages/verified-pages.tsx"), +); +const TemplateList = lazy(() => import("@/ee/template/pages/template-list")); +const TemplateEditor = lazy( + () => import("@/ee/template/pages/template-editor"), +); +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(); useRedirectToCloudSelect(); useTrackOrigin(); + useEffect(() => { + // warm the editor chunk so opening a page doesn't wait on the network + const timer = setTimeout(() => import("@/pages/page/page"), 3000); + return () => clearTimeout(timer); + }, []); + return ( - <> + } /> } /> @@ -63,6 +106,7 @@ export default function App() { } /> } /> } /> + } /> {!isCloud() && ( } /> @@ -84,6 +128,15 @@ export default function App() { } /> + } /> + }> + } /> + } + /> + + } /> } /> } /> @@ -116,6 +169,10 @@ export default function App() { element={} /> } /> + } + /> } /> } /> } /> @@ -127,6 +184,11 @@ export default function App() { } /> } /> } /> + } /> + } + /> } /> {!isCloud() && } />} {isCloud() && } />} @@ -135,6 +197,6 @@ export default function App() { } /> - + ); } diff --git a/apps/client/src/components/layouts/global/aside.tsx b/apps/client/src/components/layouts/global/aside.tsx index 556adbf17..856e02141 100644 --- a/apps/client/src/components/layouts/global/aside.tsx +++ b/apps/client/src/components/layouts/global/aside.tsx @@ -1,17 +1,30 @@ import { ActionIcon, Box, Group, ScrollArea, Title, Tooltip } from "@mantine/core"; import { IconX } from "@tabler/icons-react"; -import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx"; import { useAtom } from "jotai"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import React, { ReactNode, useEffect } from "react"; +import React, { lazy, ReactNode, Suspense, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; import { useAtomValue } from "jotai"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; -import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel"; -import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx"; import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx"; +const CommentListWithTabs = lazy( + () => import("@/features/comment/components/comment-list-with-tabs.tsx"), +); +const TableOfContents = lazy(() => + import( + "@/features/editor/components/table-of-contents/table-of-contents.tsx" + ).then((m) => ({ default: m.TableOfContents })), +); +const AsideChatPanel = lazy( + () => import("@/ee/ai-chat/components/aside-chat-panel"), +); +const PageDetailsAside = lazy(() => + import("@/features/page-details/components/page-details-aside.tsx").then( + (m) => ({ default: m.PageDetailsAside }), + ), +); + export default function Aside() { const [{ tab, isAsideOpen }, setAsideState] = useAtom(asideStateAtom); const { t } = useTranslation(); @@ -68,17 +81,19 @@ export default function Aside() { )} - {tab === "comments" || tab === "chat" ? ( - component - ) : ( - -
{component}
-
- )} + + {tab === "comments" || tab === "chat" ? ( + component + ) : ( + +
{component}
+
+ )} +
)} diff --git a/apps/client/src/components/layouts/global/global-app-shell.tsx b/apps/client/src/components/layouts/global/global-app-shell.tsx index f35029d40..272ceba62 100644 --- a/apps/client/src/components/layouts/global/global-app-shell.tsx +++ b/apps/client/src/components/layouts/global/global-app-shell.tsx @@ -11,7 +11,10 @@ import { sidebarWidthAtom, } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx"; -import AiChatSidebar from "@/ee/ai-chat/components/ai-chat-sidebar.tsx"; + +const AiChatSidebar = React.lazy( + () => import("@/ee/ai-chat/components/ai-chat-sidebar.tsx"), +); import { AppHeader } from "@/components/layouts/global/app-header.tsx"; import Aside from "@/components/layouts/global/aside.tsx"; import classes from "./app-shell.module.css"; @@ -126,7 +129,11 @@ export default function GlobalAppShell({ )} {isSpaceRoute && } {isSettingsRoute && } - {isAiRoute && } + {isAiRoute && ( + + + + )} {showGlobalSidebar && } diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 542cad910..1a1aec857 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -118,7 +118,7 @@ const groupedData: DataGroup[] = [ role: "admin", }, { - label: "Audit log", + label: "Audit logs & SIEM", icon: IconHistory, path: "/settings/audit", feature: Feature.AUDIT_LOGS, @@ -219,7 +219,7 @@ export default function SettingsSidebar() { case "API management": prefetchHandler = prefetchApiKeyManagement; break; - case "Audit log": + case "Audit logs & SIEM": prefetchHandler = prefetchAuditLogs; break; case "Verified pages": diff --git a/apps/client/src/components/ui/checkbox-menu-item.tsx b/apps/client/src/components/ui/checkbox-menu-item.tsx new file mode 100644 index 000000000..12b1f990e --- /dev/null +++ b/apps/client/src/components/ui/checkbox-menu-item.tsx @@ -0,0 +1,12 @@ +import { UnstyledButton } from "@mantine/core"; +import { type ComponentPropsWithoutRef, forwardRef } from "react"; + +// Menu.Item hard-codes role="menuitem"; use as its `component` to restore role="menuitemcheckbox" so aria-checked works. +export const CheckboxMenuItem = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<"button"> +>((props, ref) => ( + +)); + +CheckboxMenuItem.displayName = "CheckboxMenuItem"; diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx new file mode 100644 index 000000000..732800299 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-read-only.tsx @@ -0,0 +1,74 @@ +import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +export default function AiChatReadOnly() { + const { t } = useTranslation(); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + + return ( + +
+ + {t("Read-only mode")} + {!hasAccess && ( + + {t("Enterprise")} + + )} + + + {t( + "AI Chat can search and read workspace content, but cannot create or edit pages.", + )} + +
+ + +
+ ); +} + +function AiChatReadOnlyToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.ai?.chatReadOnly, + ); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + aiChatReadOnly: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx b/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx new file mode 100644 index 000000000..497a337b4 --- /dev/null +++ b/apps/client/src/ee/ai-chat/components/ai-chat-workspace-knowledge-only.tsx @@ -0,0 +1,74 @@ +import { Badge, Group, Text, Switch, Tooltip } from "@mantine/core"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; + +export default function AiChatWorkspaceKnowledgeOnly() { + const { t } = useTranslation(); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + + return ( + +
+ + {t("Workspace knowledge only")} + {!hasAccess && ( + + {t("Enterprise")} + + )} + + + {t( + "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.", + )} + +
+ + +
+ ); +} + +function AiChatWorkspaceKnowledgeOnlyToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.ai?.chatWorkspaceKnowledgeOnly, + ); + const hasAccess = useHasFeature(Feature.AI_CONTROLS); + const upgradeLabel = useUpgradeLabel(); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + aiChatWorkspaceKnowledgeOnly: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err: any) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + + + ); +} diff --git a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx index 9583a2f03..423557c53 100644 --- a/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx +++ b/apps/client/src/ee/ai-chat/components/chat-empty-state.tsx @@ -6,7 +6,10 @@ import { IconFileText, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; -import ChatInput from "./chat-input"; +import { useAtomValue } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useRef } from "react"; +import ChatInput, { ChatInputHandle } from "./chat-input"; import type { ChatAttachment, PageMention } from "../types/ai-chat.types"; import classes from "../styles/ai-chat.module.css"; @@ -14,6 +17,7 @@ type Suggestion = { icon: React.ReactNode; text: string; prompt: string; + write?: boolean; }; const SUGGESTIONS: Suggestion[] = [ @@ -26,6 +30,7 @@ const SUGGESTIONS: Suggestion[] = [ icon: , text: "Create a new page", prompt: "Create a new page titled ", + write: true, }, { icon: , @@ -36,6 +41,7 @@ const SUGGESTIONS: Suggestion[] = [ icon: , text: "Update page content", prompt: "Update the page @", + write: true, }, ]; @@ -47,9 +53,13 @@ type Props = { export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) { const { t } = useTranslation(); + const workspace = useAtomValue(workspaceAtom); + const writesDisabled = workspace?.settings?.ai?.chatReadOnly === true; + + const inputRef = useRef(null); const handleSuggestionClick = (prompt: string) => { - onSend(prompt, [], []); + inputRef.current?.prefill(prompt); }; return ( @@ -62,6 +72,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {

{t("Get started")}

- {SUGGESTIONS.map((s) => ( + {SUGGESTIONS.filter((s) => !writesDisabled || !s.write).map((s) => ( - - ) : restrictToAdmins ? ( - }> - - {t("API key creation is restricted to admins by your workspace administrator.")} + + + + {t("API keys")} + + + {t("Authorized apps")} + + + + + + + + , + }} + /> - - ) : null} - + {canCreate && ( + + )} + - + {!canCreate && restrictToAdmins && ( + }> + + {t("API key creation is restricted to admins by your workspace administrator.")} + + + )} - {data?.items.length > 0 && ( - goNext(data?.meta?.nextCursor)} - onPrev={goPrev} + - )} + + + + {data?.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + + + + + + = { "user.role_changed": "Changed user role", "user.password_changed": "Changed password", "user.password_reset": "Reset password", + "user.password_reset_requested": "Requested password reset", "user.updated": "Updated user", "user.deactivated": "Deactivated user", "user.activated": "Activated user", @@ -62,6 +63,7 @@ export const auditEventLabels: Record = { "page.restriction_removed": "Removed page restriction", "page.permission_added": "Added page permission", "page.permission_removed": "Removed page permission", + "page.permission_role_changed": "Changed page permission", "page.verification_created": "Created page verification", "page.verification_updated": "Updated page verification", "page.verification_removed": "Removed page verification", @@ -79,6 +81,13 @@ export const auditEventLabels: Record = { "license.activated": "Activated license", "license.removed": "Removed license", + + "siem_destination.created": "Created SIEM destination", + "siem_destination.updated": "Updated SIEM destination", + "siem_destination.deleted": "Deleted SIEM destination", + + "template.created": "Created template", + "template.deleted": "Deleted template", }; export function getEventLabel(event: string): string { @@ -105,6 +114,10 @@ export const eventFilterOptions: EventGroup[] = [ { value: "user.activated", label: "Activated user" }, { value: "user.role_changed", label: "Changed user role" }, { value: "user.password_changed", label: "Changed password" }, + { + value: "user.password_reset_requested", + label: "Requested password reset", + }, { value: "user.mfa_enabled", label: "Enabled MFA" }, { value: "user.mfa_disabled", label: "Disabled MFA" }, ], @@ -147,6 +160,10 @@ export const eventFilterOptions: EventGroup[] = [ { value: "page.restriction_removed", label: "Removed page restriction" }, { value: "page.permission_added", label: "Added page permission" }, { value: "page.permission_removed", label: "Removed page permission" }, + { + value: "page.permission_role_changed", + label: "Changed page permission", + }, { value: "page.verification_created", label: "Created page verification" }, { value: "page.verification_updated", label: "Updated page verification" }, { value: "page.verification_removed", label: "Removed page verification" }, @@ -193,4 +210,19 @@ export const eventFilterOptions: EventGroup[] = [ { value: "license.removed", label: "Removed license" }, ], }, + { + group: "SIEM", + items: [ + { value: "siem_destination.created", label: "Created SIEM destination" }, + { value: "siem_destination.updated", label: "Updated SIEM destination" }, + { value: "siem_destination.deleted", label: "Deleted SIEM destination" }, + ], + }, + { + group: "Template", + items: [ + { value: "template.created", label: "Created template" }, + { value: "template.deleted", label: "Deleted template" }, + ], + }, ]; diff --git a/apps/client/src/ee/audit/pages/audit-logs.tsx b/apps/client/src/ee/audit/pages/audit-logs.tsx index 811e094e8..cd55ed43b 100644 --- a/apps/client/src/ee/audit/pages/audit-logs.tsx +++ b/apps/client/src/ee/audit/pages/audit-logs.tsx @@ -7,10 +7,12 @@ import { Popover, Select, Space, + Tabs, Text, Tooltip, } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; import { IconSettings } from "@tabler/icons-react"; import SettingsTitle from "@/components/settings/settings-title"; import Paginate from "@/components/common/paginate"; @@ -23,6 +25,7 @@ import { import { IAuditLogParams } from "@/ee/audit/types/audit.types"; import { eventFilterOptions } from "@/ee/audit/lib/audit-event-labels"; import AuditLogsTable from "@/ee/audit/components/audit-logs-table"; +import SiemStreamingPanel from "@/ee/siem/components/siem-streaming-panel"; import useUserRole from "@/hooks/use-user-role"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; @@ -48,6 +51,8 @@ export default function AuditLogs() { const { t } = useTranslation(); const { isOwner } = useUserRole(); const { cursor, goNext, goPrev, resetCursor } = useCursorPaginate(); + const location = useLocation(); + const navigate = useNavigate(); const [eventFilter, setEventFilter] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); @@ -85,6 +90,8 @@ export default function AuditLogs() { const { data, isLoading } = useAuditLogsQuery(params); + const activeTab = location.pathname.endsWith("/siem") ? "siem" : "audit"; + if (!isOwner) { return null; } @@ -94,125 +101,150 @@ export default function AuditLogs() { resetCursor(); }; + const handleTabChange = (value: string | null) => { + if (value === "siem") { + navigate("/settings/audit/siem"); + } else { + navigate("/settings/audit"); + } + }; + return ( <> - + - + - - { - if (value === "days" || value === "months" || value === "years") { - setRetentionUnit(value); - } - }} - size="sm" - style={{ flex: 1 }} - comboboxProps={{ withinPortal: false }} - /> - - - - - - - - + + + { + if (value === "days" || value === "months" || value === "years") { + setRetentionUnit(value); + } + }} + size="sm" + style={{ flex: 1 }} + comboboxProps={{ withinPortal: false }} + /> + + + + + + + + - + - {data?.items && data.items.length > 0 && ( - goNext(data?.meta?.nextCursor)} - onPrev={goPrev} - /> - )} + + + {data?.items && data.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + + + + + ); } diff --git a/apps/client/src/ee/base/components/views/view-filter-config.tsx b/apps/client/src/ee/base/components/views/view-filter-config.tsx index c860845d5..c0c4c463d 100644 --- a/apps/client/src/ee/base/components/views/view-filter-config.tsx +++ b/apps/client/src/ee/base/components/views/view-filter-config.tsx @@ -9,6 +9,7 @@ import { Text, UnstyledButton, Button, + MultiSelect, } from "@mantine/core"; import { IconPlus, IconTrash } from "@tabler/icons-react"; import { @@ -52,6 +53,9 @@ const NO_VALUE_OPERATORS: FilterOperator[] = ["isEmpty", "isNotEmpty"]; // stored value so a stale shape isn't sent to the engine. function valueClass(op: FilterOperator, inputKind: string): string { if (NO_VALUE_OPERATORS.includes(op)) return "none"; + if (inputKind === "choices") { + return op === "any" || op === "none" ? "choicesMulti" : "choicesSingle"; + } if (inputKind === "person") { return op === "any" || op === "none" ? "personMulti" : "personSingle"; } @@ -70,6 +74,10 @@ function getOperatorsForType(type: string): FilterOperator[] { DEFAULT_FILTER_OPERATORS) as FilterOperator[]; } +function isMultiChoice(op: FilterCondition["op"]): boolean { + return op === "any" || op === "none"; +} + function FilterValueInput({ condition, property, @@ -121,6 +129,32 @@ function FilterValueInput({ const typeOptions = property.typeOptions as SelectTypeOptions | undefined; const choices = typeOptions?.choices ?? []; const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name })); + + if (isMultiChoice(condition.op)) { + const { value } = condition; + const selected = ( + Array.isArray(value) ? value : value ? [value] : [] + ).filter((id) => choices.some((c) => c.id === id)); + + return ( + onChange(values)} + w={160} + styles={{ + pillsList: { + maxHeight: 70, + overflowY: "auto", + }, + }} + maxDropdownHeight={220} + /> + ); + } + return ( + + + + {type === "splunk_hec" && ( + <> + + + + )} + + {type === "datadog" && ( + <> + + + )} + + {showTls && ( + <> + {!isCloud() && ( + + )} + + )} + + + + + + {showTestResult && ( + : } + > + {testState.result.delivered ? t("Test event delivered successfully.") : testState.result.error} + + )} + + + + + {requiresTest && ( + + {t("Test the connection before saving.")} + + )} + + + + + + + + + + ); +} diff --git a/apps/client/src/ee/siem/components/destination-status-badge.tsx b/apps/client/src/ee/siem/components/destination-status-badge.tsx new file mode 100644 index 000000000..17618aab8 --- /dev/null +++ b/apps/client/src/ee/siem/components/destination-status-badge.tsx @@ -0,0 +1,15 @@ +import { Badge } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ISiemDestination } from "@/ee/siem/types/siem.types"; + +export function DestinationStatusBadge({ destination }: { destination: ISiemDestination }) { + const { t } = useTranslation(); + + if (!destination.enabled) { + return {t("Disabled")}; + } + if (destination.status === "failing") { + return {t("Failing")}; + } + return {t("Healthy")}; +} diff --git a/apps/client/src/ee/siem/components/destination-table.tsx b/apps/client/src/ee/siem/components/destination-table.tsx new file mode 100644 index 000000000..c2d540a43 --- /dev/null +++ b/apps/client/src/ee/siem/components/destination-table.tsx @@ -0,0 +1,155 @@ +import { ActionIcon, Menu, Switch, Table, Text, Tooltip } from "@mantine/core"; +import { + IconDots, + IconEdit, + IconPlugConnected, + IconRefresh, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { formattedDate, timeAgo } from "@/lib/time.ts"; +import { ISiemDestination, SiemDestinationType } from "@/ee/siem/types/siem.types"; +import { DestinationStatusBadge } from "./destination-status-badge"; + +export const DESTINATION_TYPE_LABELS: Record = { + splunk_hec: "Splunk HEC", + datadog: "Datadog", + http: "Generic HTTP", +}; + +interface DestinationTableProps { + destinations?: ISiemDestination[]; + isLoading?: boolean; + onEdit: (destination: ISiemDestination) => void; + onTest: (destination: ISiemDestination) => void; + onRetry: (destination: ISiemDestination) => void; + onDelete: (destination: ISiemDestination) => void; + onToggle: (destination: ISiemDestination, enabled: boolean) => void; +} + +export function DestinationTable({ + destinations, + isLoading, + onEdit, + onTest, + onRetry, + onDelete, + onToggle, +}: DestinationTableProps) { + const { t } = useTranslation(); + + return ( + + + + + {t("Name")} + {t("Type")} + {t("Enabled")} + {t("Status")} + {t("Last delivered")} + {t("Last error")} + + + + + {destinations && destinations.length > 0 ? ( + destinations.map((destination) => ( + + + {destination.name} + + + {DESTINATION_TYPE_LABELS[destination.type]} + + + onToggle(destination, event.currentTarget.checked)} + aria-label={t("Enabled")} + /> + + + + {destination.failingSince && + (destination.status === "failing" || + !destination.enabled) && ( + + {t("Failing since {{time}}", { + time: formattedDate( + new Date(destination.failingSince), + ), + })} + + )} + + + + {destination.lastDeliveredAt + ? timeAgo(new Date(destination.lastDeliveredAt)) + : t("Never")} + + + + {destination.lastError ? ( + + + {destination.lastError} + + + ) : ( + + )} + + + + + + + + + + } onClick={() => onEdit(destination)}> + {t("Edit")} + + } onClick={() => onTest(destination)}> + {t("Send test event")} + + } + onClick={() => onRetry(destination)} + disabled={!destination.nextAttemptAt} + > + {t("Retry now")} + + + } onClick={() => onDelete(destination)}> + {t("Delete")} + + + + + + )) + ) : ( + !isLoading && ( + + + + {t("No destinations yet")} + + + + ) + )} + +
+
+ ); +} diff --git a/apps/client/src/ee/siem/components/siem-streaming-panel.tsx b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx new file mode 100644 index 000000000..e54230de3 --- /dev/null +++ b/apps/client/src/ee/siem/components/siem-streaming-panel.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { Alert, Button, Group, Tooltip } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import useUserRole from "@/hooks/use-user-role"; +import { useHasFeature } from "@/ee/hooks/use-feature"; +import { Feature } from "@/ee/features"; +import { + ISiemDestination, + SIEM_MAX_DESTINATIONS_PER_WORKSPACE, +} from "@/ee/siem/types/siem.types"; +import { + useRetrySiemDestinationMutation, + useSiemDestinationsQuery, + extractErrorMessage, + useTestSiemDestinationMutation, + useUpdateSiemDestinationMutation, +} from "@/ee/siem/queries/siem-query"; +import { DestinationTable } from "@/ee/siem/components/destination-table"; +import { DestinationFormModal } from "@/ee/siem/components/destination-form-modal"; +import { DeleteDestinationModal } from "@/ee/siem/components/delete-destination-modal"; + +export default function SiemStreamingPanel() { + const { t } = useTranslation(); + const { isOwner } = useUserRole(); + const hasFeature = useHasFeature(Feature.SIEM); + const { data, isLoading, isError, error } = useSiemDestinationsQuery(hasFeature); + const updateMutation = useUpdateSiemDestinationMutation(); + const retryMutation = useRetrySiemDestinationMutation(); + const testMutation = useTestSiemDestinationMutation(); + const [formOpened, setFormOpened] = useState(false); + const [deleteOpened, setDeleteOpened] = useState(false); + const [selected, setSelected] = useState(null); + + if (!isOwner) { + return null; + } + + const atDestinationLimit = + (data?.length ?? 0) >= SIEM_MAX_DESTINATIONS_PER_WORKSPACE; + + const handleTest = async (destination: ISiemDestination) => { + const result = await testMutation + .mutateAsync({ + type: destination.type, + config: destination.config as unknown as Record, + destinationId: destination.id, + }) + .catch(() => null); + if (!result) return; + notifications.show({ + message: result.delivered + ? t("Test event delivered to {{name}}", { name: destination.name }) + : result.error, + color: result.delivered ? "green" : "red", + }); + }; + + return ( + <> + {!hasFeature && ( + } color="yellow" mb="md"> + {t("SIEM streaming requires an Enterprise license.")} + + )} + + + + + + + + + + {isError && ( + } color="red" mb="md"> + {t("Could not load SIEM destinations: {{message}}", { + message: extractErrorMessage(error), + })} + + )} + + {hasFeature && !isError && ( + { + setSelected(destination); + setFormOpened(true); + }} + onTest={handleTest} + onRetry={(destination) => retryMutation.mutate({ destinationId: destination.id })} + onDelete={(destination) => { + setSelected(destination); + setDeleteOpened(true); + }} + onToggle={(destination, enabled) => + updateMutation.mutate({ destinationId: destination.id, enabled }) + } + /> + )} + + setFormOpened(false)} + destination={selected} + /> + setDeleteOpened(false)} + destination={selected} + /> + + ); +} diff --git a/apps/client/src/ee/siem/lib/destination-form.ts b/apps/client/src/ee/siem/lib/destination-form.ts new file mode 100644 index 000000000..4a52cc4f6 --- /dev/null +++ b/apps/client/src/ee/siem/lib/destination-form.ts @@ -0,0 +1,179 @@ +import { + DATADOG_SITES, + ISiemDestination, + ISiemDestinationInput, + SiemDestinationType, +} from "@/ee/siem/types/siem.types"; + +export type DestinationFormValues = { + name: string; + type: SiemDestinationType; + url: string; + token: string; + apiKey: string; + authHeaderName: string; + authHeaderPrefix: string; + format: "json" | "ndjson"; + index: string; + source: string; + sourcetype: string; + host: string; + site: string; + service: string; + tags: string; + rejectUnauthorized: boolean; + enabled: boolean; +}; + +export const DEFAULT_FORM_VALUES: DestinationFormValues = { + name: "", + type: "splunk_hec", + url: "", + token: "", + apiKey: "", + authHeaderName: "Authorization", + authHeaderPrefix: "Bearer ", + format: "json", + index: "", + source: "docmost", + sourcetype: "docmost:audit", + host: "", + site: DATADOG_SITES[0], + service: "docmost", + tags: "", + rejectUnauthorized: true, + enabled: true, +}; + +const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/; +export const SECRET_MASK = "********"; + +export function initialValues( + destination?: ISiemDestination | null, +): DestinationFormValues { + if (!destination) return { ...DEFAULT_FORM_VALUES }; + const config = destination.config as Record; + const tls = config.tls ?? {}; + return { + ...DEFAULT_FORM_VALUES, + name: destination.name, + type: destination.type, + enabled: destination.enabled, + token: destination.hasSecrets?.token ? SECRET_MASK : "", + apiKey: destination.hasSecrets?.apiKey ? SECRET_MASK : "", + url: config.url ?? "", + authHeaderName: config.authHeaderName ?? DEFAULT_FORM_VALUES.authHeaderName, + authHeaderPrefix: config.authHeaderPrefix ?? DEFAULT_FORM_VALUES.authHeaderPrefix, + format: config.format ?? "json", + index: config.index ?? "", + source: config.source ?? DEFAULT_FORM_VALUES.source, + sourcetype: config.sourcetype ?? DEFAULT_FORM_VALUES.sourcetype, + host: config.host ?? "", + site: config.site ?? DEFAULT_FORM_VALUES.site, + service: config.service ?? DEFAULT_FORM_VALUES.service, + tags: config.tags ?? "", + rejectUnauthorized: tls.rejectUnauthorized ?? true, + }; +} + +function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +export function validateForm( + values: DestinationFormValues, + hasSecrets: Record = {}, +): Partial> { + const errors: Partial> = {}; + + if (!values.name.trim()) errors.name = "Name is required"; + + if (values.type !== "datadog" && !isValidUrl(values.url.trim())) { + errors.url = "Enter a valid http(s) URL"; + } + + if (values.type === "splunk_hec") { + if (!values.token && !hasSecrets.token) { + errors.token = "HEC token is required"; + } + try { + const url = new URL(values.url.trim()); + const path = url.pathname.replace(/\/+$/, ""); + if (path !== "" && path !== "/services/collector" && path !== "/services/collector/event") { + errors.url = "Enter the HEC base URL or the /services/collector/event endpoint"; + } + } catch {} + } + + if (values.type === "datadog") { + if (!(DATADOG_SITES as readonly string[]).includes(values.site)) { + errors.site = "Select a Datadog site"; + } + if (!values.apiKey && !hasSecrets.apiKey) errors.apiKey = "API key is required"; + } + + if (values.type === "http") { + if (!HEADER_NAME_RE.test(values.authHeaderName.trim())) { + errors.authHeaderName = "Use letters, digits and hyphens only"; + } + } + + + return errors; +} + +function enteredSecret(key: string, value: string): Record { + const trimmed = value.trim(); + return trimmed && trimmed !== SECRET_MASK ? { [key]: trimmed } : {}; +} + +export function toPayload(values: DestinationFormValues): ISiemDestinationInput { + const tls = { rejectUnauthorized: values.rejectUnauthorized }; + + let config: Record; + let secrets: Record; + + switch (values.type) { + case "splunk_hec": + config = { + url: values.url.trim(), + index: values.index.trim(), + source: values.source.trim() || "docmost", + sourcetype: values.sourcetype.trim() || "docmost:audit", + host: values.host.trim(), + tls, + }; + secrets = enteredSecret("token", values.token); + break; + case "datadog": + config = { + site: values.site, + service: values.service.trim() || "docmost", + tags: values.tags.trim(), + }; + secrets = enteredSecret("apiKey", values.apiKey); + break; + default: + config = { + url: values.url.trim(), + authHeaderName: values.authHeaderName.trim(), + authHeaderPrefix: values.authHeaderPrefix, + format: values.format, + tls, + }; + secrets = enteredSecret("token", values.token); + } + + return { + name: values.name.trim(), + type: values.type, + config, + secrets: Object.fromEntries(Object.entries(secrets).filter(([, v]) => v !== "")), + enabled: values.enabled, + }; +} diff --git a/apps/client/src/ee/siem/queries/siem-query.ts b/apps/client/src/ee/siem/queries/siem-query.ts new file mode 100644 index 000000000..e3f814c04 --- /dev/null +++ b/apps/client/src/ee/siem/queries/siem-query.ts @@ -0,0 +1,115 @@ +import { + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + createSiemDestination, + deleteSiemDestination, + getSiemDestinations, + retrySiemDestination, + testSiemDestination, + updateSiemDestination, +} from "@/ee/siem/services/siem-service"; +import { + ISiemDestination, + ISiemDestinationInput, + ISiemTestResult, + ITestSiemDestinationInput, + IUpdateSiemDestinationInput, +} from "@/ee/siem/types/siem.types"; + +export const SIEM_DESTINATIONS_KEY = ["siem-destinations"]; + +export function extractErrorMessage(error: Error): string { + const data = (error as any)?.response?.data; + const message = data?.message ?? error.message; + return Array.isArray(message) ? message.join(", ") : String(message); +} + +function showError(error: Error) { + notifications.show({ message: extractErrorMessage(error), color: "red" }); +} + +function isForbidden(error: unknown): boolean { + return (error as { response?: { status?: number } })?.response?.status === 403; +} + +export function useSiemDestinationsQuery( + enabled = true, +): UseQueryResult { + return useQuery({ + queryKey: SIEM_DESTINATIONS_KEY, + queryFn: getSiemDestinations, + enabled, + retry: (failureCount, error) => !isForbidden(error) && failureCount < 2, + refetchInterval: (query) => (query.state.status === "error" ? false : 15_000), + }); +} + +function useInvalidateDestinations() { + const queryClient = useQueryClient(); + return () => queryClient.invalidateQueries({ queryKey: SIEM_DESTINATIONS_KEY }); +} + +export function useCreateSiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: createSiemDestination, + onSuccess: () => { + notifications.show({ message: t("Destination created") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useUpdateSiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: updateSiemDestination, + onSuccess: () => { + notifications.show({ message: t("Destination updated") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useDeleteSiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: deleteSiemDestination, + onSuccess: () => { + notifications.show({ message: t("Destination deleted") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useRetrySiemDestinationMutation() { + const { t } = useTranslation(); + const invalidate = useInvalidateDestinations(); + return useMutation({ + mutationFn: retrySiemDestination, + onSuccess: () => { + notifications.show({ message: t("Retry scheduled") }); + invalidate(); + }, + onError: showError, + }); +} + +export function useTestSiemDestinationMutation() { + return useMutation({ + mutationFn: testSiemDestination, + onError: showError, + }); +} diff --git a/apps/client/src/ee/siem/services/siem-service.ts b/apps/client/src/ee/siem/services/siem-service.ts new file mode 100644 index 000000000..0c394ecf3 --- /dev/null +++ b/apps/client/src/ee/siem/services/siem-service.ts @@ -0,0 +1,46 @@ +import api from "@/lib/api-client"; +import { + ISiemDestination, + ISiemDestinationInput, + ISiemTestResult, + ITestSiemDestinationInput, + IUpdateSiemDestinationInput, +} from "@/ee/siem/types/siem.types"; + +export async function getSiemDestinations(): Promise { + const req = await api.post("/siem/destinations"); + return req.data; +} + +export async function createSiemDestination( + data: ISiemDestinationInput, +): Promise { + const req = await api.post("/siem/destinations/create", data); + return req.data; +} + +export async function updateSiemDestination( + data: IUpdateSiemDestinationInput, +): Promise { + const req = await api.post("/siem/destinations/update", data); + return req.data; +} + +export async function deleteSiemDestination(data: { + destinationId: string; +}): Promise { + await api.post("/siem/destinations/delete", data); +} + +export async function testSiemDestination( + data: ITestSiemDestinationInput, +): Promise { + const req = await api.post("/siem/destinations/test", data); + return req.data; +} + +export async function retrySiemDestination(data: { + destinationId: string; +}): Promise { + await api.post("/siem/destinations/retry", data); +} diff --git a/apps/client/src/ee/siem/types/siem.types.ts b/apps/client/src/ee/siem/types/siem.types.ts new file mode 100644 index 000000000..7e8a17232 --- /dev/null +++ b/apps/client/src/ee/siem/types/siem.types.ts @@ -0,0 +1,91 @@ +export const SIEM_MAX_DESTINATIONS_PER_WORKSPACE = 2; + +export type SiemDestinationType = "http" | "splunk_hec" | "datadog"; +export type SiemDestinationStatus = "healthy" | "failing"; + +export const DATADOG_SITES = [ + "datadoghq.com", + "datadoghq.eu", + "us3.datadoghq.com", + "us5.datadoghq.com", + "ap1.datadoghq.com", + "ddog-gov.com", +] as const; + +export interface ITlsOptions { + rejectUnauthorized: boolean; +} + +export interface IHttpConfig { + url: string; + authHeaderName: string; + authHeaderPrefix: string; + format: "json" | "ndjson"; + tls?: ITlsOptions; +} + +export interface ISplunkHecConfig { + url: string; + index?: string; + source: string; + sourcetype: string; + host?: string; + channelId: string; + tls?: ITlsOptions; +} + +export interface IDatadogConfig { + site: string; + service: string; + tags?: string; +} + +export type ISiemConfig = IHttpConfig | ISplunkHecConfig | IDatadogConfig; + +export interface ISiemDestination { + id: string; + name: string; + type: SiemDestinationType; + enabled: boolean; + status: SiemDestinationStatus; + config: ISiemConfig; + hasSecrets: Record; + cursorCreatedAt: string; + lastDeliveredAt: string | null; + lastError: string | null; + lastErrorAt: string | null; + consecutiveFailures: number; + nextAttemptAt: string | null; + failingSince: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ISiemDestinationInput { + name: string; + type: SiemDestinationType; + config: Record; + secrets?: Record; + enabled?: boolean; +} + +export interface IUpdateSiemDestinationInput { + destinationId: string; + name?: string; + config?: Record; + secrets?: Record; + enabled?: boolean; +} + +export interface ITestSiemDestinationInput { + type: SiemDestinationType; + config: Record; + secrets?: Record; + destinationId?: string; +} + +export interface ISiemTestResult { + delivered: boolean; + error?: string; + statusCode?: number; +} diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 74692d916..2de76fe70 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -16,6 +16,14 @@ export const showAiMenuAtom = atom(false); export const showLinkMenuAtom = atom(false); +export type LightboxRequest = { + src: string; + type: "image" | "video"; +} | null; + +const initialLightboxRequest: LightboxRequest = null; +export const lightboxRequestAtom = atom(initialLightboxRequest); + // Current page's edit mode — initialized from the user's saved preference on // first load, can be toggled locally without persisting to the server. export const currentPageEditModeAtom = atom(PageEditMode.Edit); diff --git a/apps/client/src/features/editor/components/audio/audio-menu.tsx b/apps/client/src/features/editor/components/audio/audio-menu.tsx index eadc1afe5..382bc9afd 100644 --- a/apps/client/src/features/editor/components/audio/audio-menu.tsx +++ b/apps/client/src/features/editor/components/audio/audio-menu.tsx @@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) { { + if (element) element.style.zIndex = "99"; + }} updateDelay={0} getReferencedVirtualElement={getReferencedVirtualElement} options={{ diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index a1283e34a..02108292e 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -23,11 +23,21 @@ import { } from "@/features/comment/atoms/comment-atom"; import { useAtom, useAtomValue } from "jotai"; import { v7 as uuid7 } from "uuid"; -import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext"; +import { + isCellSelection, + isEditorReady, + isTextSelected, +} from "@docmost/editor-ext"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { useTranslation } from "react-i18next"; -import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms"; -import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom"; +import { + showAiMenuAtom, + showLinkMenuAtom, +} from "@/features/editor/atoms/editor-atoms"; +import { + userAtom, + workspaceAtom, +} from "@/features/user/atoms/current-user-atom"; export interface BubbleMenuItem { name: string; @@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC = (props) => { {items.map((item, index) => ( - + = (props) => { aria-label={t(item.name)} className={clsx({ [classes.active]: item.isActive() })} style={{ border: "none" }} - onClick={() => isEditorReady(props.editor) && item.command()} + onClick={() => + isEditorReady(props.editor) && item.command() + } > @@ -256,7 +273,9 @@ export const EditorBubbleMenu: FC = (props) => { radius="6px" aria-label={t(commentItem.name)} style={{ border: "none" }} - onClick={() => isEditorReady(props.editor) && commentItem.command()} + onClick={() => + isEditorReady(props.editor) && commentItem.command() + } > diff --git a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx index a228dc645..533bcda8a 100644 --- a/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/color-selector.tsx @@ -129,8 +129,7 @@ function handleColorKeyNav( grid: "text" | "highlight", ) { const cols = COLOR_GRID_COLS; - const total = - grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length; + const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length; const col = index % cols; if (e.key === "ArrowRight") { @@ -163,8 +162,7 @@ function handleColorKeyNav( if (prev >= 0) { focusSwatch(grid, prev); } else if (grid === "highlight") { - const lastRowStart = - Math.floor((TEXT_COLORS.length - 1) / cols) * cols; + const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols; focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1)); } return; @@ -222,7 +220,7 @@ export const ColorSelector: FC = ({ withArrow > - + - + + ); diff --git a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx index fdacf6a18..0b7992f18 100644 --- a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx @@ -10,7 +10,7 @@ export const LinkSelector: FC = () => { const setShowLinkMenu = useSetAtom(showLinkMenuAtom); return ( - + = ({ onChange={setIsOpen} > - + + + + {hidden.map((item) => ( + + {item.name} + + ))} + + , + ); + } + const trailing = collapsed ? [crumbs[crumbs.length - 1]] : crumbs; + for (const crumb of trailing) { + items.push( + + {crumb.name} + , + ); + } + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx new file mode 100644 index 000000000..119407dad --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-copy-page.tsx @@ -0,0 +1,45 @@ +import { Button } from "@mantine/core"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; +import { htmlToMarkdown } from "@docmost/editor-ext"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts"; +import { useClipboard } from "@/hooks/use-clipboard"; +import styles from "./docs.module.css"; + +export default function DocsCopyPage() { + const { t } = useTranslation(); + const editor = useAtomValue(readOnlyEditorAtom); + const page = useDocsCurrentPage(); + const clipboard = useClipboard(); + + if (!editor) { + return null; + } + + const handleCopy = () => { + if (editor.isDestroyed) return; + const markdown = htmlToMarkdown(editor.getHTML()); + const title = page?.name ? `# ${page.name}\n\n` : ""; + clipboard.copy(`${title}${markdown}`); + }; + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx new file mode 100644 index 000000000..77ef37c9b --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-edit-page.tsx @@ -0,0 +1,31 @@ +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconPencil } from "@tabler/icons-react"; +import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts"; +import { useDocsCurrentPage } from "@/features/public-space/hooks/use-docs-current-page.ts"; +import { buildPageUrl } from "@/features/page/page.utils.ts"; +import styles from "./docs.module.css"; + +export default function DocsEditPage() { + const { t } = useTranslation(); + const { spaceSlug } = useParams(); + const page = useDocsCurrentPage(); + + const { data: currentUser } = useAuthenticatedUser(); + + if (!currentUser?.user || !page) { + return null; + } + + return ( + + + {t("Edit page")} + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx new file mode 100644 index 000000000..9e55ebba3 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-footer-branding.tsx @@ -0,0 +1,23 @@ +import clsx from "clsx"; +import styles from "./docs.module.css"; + +export default function DocsFooterBranding({ + className, + refSource = "public-space", +}: { + className?: string; + refSource?: string; +}) { + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-hub.module.css b/apps/client/src/features/public-space/components/docs/docs-hub.module.css new file mode 100644 index 000000000..53a93362c --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-hub.module.css @@ -0,0 +1,485 @@ +/* Design tokens for the public docs hub, transcribed from the "1a solid brand + * band" direction of the Directory design spec (a fixed light look). Declared + * on :root like --docs-* so cover/brand customization can override them at + * runtime. */ +:root { + --docs-hub-max: 80rem; + --docs-hub-bg: #ffffff; + --docs-hub-fg: #111827; + --docs-hub-muted: #5b6270; + --docs-hub-nav-fg: #4b5563; + --docs-hub-footer-fg: #6b7280; + --docs-hub-border: #eceef2; + --docs-hub-brand: #12275c; + --docs-hub-brand-fg: #ffffff; + --docs-hub-hero-bg: var(--docs-hub-brand); + --docs-hub-hero-fg: #ffffff; + --docs-hub-hero-muted: rgba(255, 255, 255, 0.78); + --docs-hub-search-bg: #ffffff; + --docs-hub-search-fg: #111827; + --docs-hub-search-placeholder: #8a919e; + --docs-hub-search-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); + --docs-hub-card-bg: #ffffff; + --docs-hub-card-border: #e6e7ea; + --docs-hub-card-radius: 14px; + --docs-hub-card-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + --docs-hub-card-border-hover: #c4c8d0; + --docs-hub-card-shadow-hover: 0 12px 32px rgba(15, 23, 42, 0.14); + --docs-hub-tile-fg: #ffffff; +} + +.root { + min-height: 100dvh; + background-color: var(--docs-hub-bg); + color: var(--docs-hub-fg); +} + +.container { + max-width: var(--docs-hub-max); + margin-inline: auto; +} + +/* ---------- Top bar ---------- */ + +.topBar { + border-bottom: 1px solid var(--docs-hub-border); + padding: 0 rem(48px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: 0 rem(20px); + } +} + +.topBarInner { + height: rem(64px); + display: flex; + align-items: center; + justify-content: space-between; + + @media (max-width: $mantine-breakpoint-sm) { + height: rem(56px); + } +} + +.brand { + display: flex; + align-items: center; + gap: rem(10px); + min-width: 0; + color: var(--docs-hub-fg); + text-decoration: none; + font-size: rem(16px); + font-weight: 600; + border-radius: rem(8px); + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 4px; + } + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(8px); + font-size: rem(15px); + } +} + +.brandTile { + width: rem(28px); + height: rem(28px); + border-radius: rem(8px); + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + display: grid; + place-items: center; + flex-shrink: 0; + font-weight: 700; + font-size: rem(13px); + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(26px); + height: rem(26px); + border-radius: rem(7px); + font-size: rem(12px); + } +} + +.topActions { + display: flex; + align-items: center; + gap: rem(24px); + font-size: rem(14px); + color: var(--docs-hub-nav-fg); +} + +.signIn { + display: inline-block; + padding: rem(8px) rem(14px); + border-radius: rem(8px); + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + text-decoration: none; + font-weight: 500; + white-space: nowrap; + + @media (hover: hover) { + &:hover { + filter: brightness(1.15); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(8px) rem(12px); + font-size: rem(13px); + } +} + +/* ---------- Hero band ---------- */ + +.hero { + background-color: var(--docs-hub-hero-bg); + color: var(--docs-hub-hero-fg); + padding: rem(72px) rem(48px) rem(80px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(40px) rem(20px) rem(44px); + } +} + +.heroInner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: rem(16px); + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(10px); + } +} + +.heading { + margin: 0; + color: var(--docs-hub-hero-fg); + font-size: rem(48px); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(30px); + } +} + +.subtitle { + margin: 0; + color: var(--docs-hub-hero-muted); + font-size: rem(18px); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(15px); + line-height: 1.45; + } +} + +.search { + margin-top: rem(16px); + width: rem(640px); + max-width: 100%; + height: rem(56px); + display: flex; + align-items: center; + padding: 0 rem(6px) 0 rem(22px); + border-radius: 999px; + background-color: var(--docs-hub-search-bg); + box-shadow: var(--docs-hub-search-shadow); + + &:focus-within { + outline: 2px solid var(--docs-hub-hero-fg); + outline-offset: 3px; + } + + @media (max-width: $mantine-breakpoint-sm) { + margin-top: rem(10px); + width: 100%; + height: rem(48px); + padding: 0 rem(5px) 0 rem(18px); + box-shadow: none; + } +} + +.searchInput { + flex: 1; + min-width: 0; + height: 100%; + border: 0; + background: transparent; + font: inherit; + font-size: rem(16px); + color: var(--docs-hub-search-fg); + text-align: left; + outline: none; + + &::placeholder { + color: var(--docs-hub-search-placeholder); + } + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(15px); + } +} + +.searchButton { + width: rem(44px); + height: rem(44px); + flex-shrink: 0; + border: 0; + border-radius: 999px; + background-color: var(--docs-hub-brand); + color: var(--docs-hub-brand-fg); + display: grid; + place-items: center; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--docs-hub-search-fg); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(38px); + height: rem(38px); + } +} + +/* ---------- Spaces ---------- */ + +.main { + padding: rem(48px) rem(48px) rem(56px); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(24px) rem(20px) rem(28px); + } +} + +.mainInner { + display: flex; + flex-direction: column; + gap: rem(24px); + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(16px); + } +} + +.sectionHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: rem(16px); + border-bottom: 1px solid var(--docs-hub-border); + padding-bottom: rem(14px); + + @media (max-width: $mantine-breakpoint-sm) { + padding-bottom: rem(10px); + } +} + +.sectionTitle { + margin: 0; + font-size: rem(13px); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--docs-hub-fg); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(12px); + } +} + +.sectionCount { + font-size: rem(14px); + color: var(--docs-hub-footer-fg); + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(13px); + } +} + +/* Phones flow one card per row; wider viewports fit two, then 3/4 columns. */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(rem(220px), 1fr)); + gap: rem(12px); + + @media (min-width: $mantine-breakpoint-sm) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: rem(20px); + } + + @media (min-width: 64em) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.card { + display: flex; + flex-direction: column; + gap: rem(14px); + padding: rem(24px) rem(24px) rem(26px); + background-color: var(--docs-hub-card-bg); + border: 1px solid var(--docs-hub-card-border); + border-radius: var(--docs-hub-card-radius); + box-shadow: var(--docs-hub-card-shadow); + color: inherit; + text-decoration: none; + transition: + border-color 120ms ease, + box-shadow 160ms ease; + + @media (hover: hover) { + &:hover { + border-color: var(--docs-hub-card-border-hover); + box-shadow: var(--docs-hub-card-shadow-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + } + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(12px); + padding: rem(18px) rem(18px) rem(20px); + } +} + +/* Tile and title share a row so titles get the card's full width. */ +.cardHeader { + display: flex; + align-items: center; + gap: rem(14px); + min-width: 0; + + @media (max-width: $mantine-breakpoint-sm) { + gap: rem(12px); + } +} + +.cardTile { + width: rem(40px); + height: rem(40px); + flex-shrink: 0; + border-radius: rem(10px); + display: grid; + place-items: center; + overflow: hidden; + color: var(--docs-hub-tile-fg); + font-weight: 700; + font-size: rem(15px); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + @media (max-width: $mantine-breakpoint-sm) { + width: rem(36px); + height: rem(36px); + border-radius: rem(9px); + font-size: rem(14px); + } +} + +.cardName { + min-width: 0; + font-size: rem(18px); + font-weight: 600; + line-height: 1.3; + color: var(--docs-hub-fg); + text-wrap: balance; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(16px); + } +} + +.cardDescription { + font-size: rem(15px); + line-height: 1.5; + color: var(--docs-hub-muted); + text-wrap: pretty; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + + @media (max-width: $mantine-breakpoint-sm) { + font-size: rem(14px); + } +} + +.empty { + margin: 0; + padding: rem(48px) 0; + text-align: center; + color: var(--docs-hub-muted); +} + +/* ---------- Footer ---------- */ + +.footer { + border-top: 1px solid var(--docs-hub-border); + padding: rem(24px) rem(48px); + font-size: rem(14px); + color: var(--docs-hub-footer-fg); + + @media (max-width: $mantine-breakpoint-sm) { + padding: rem(20px); + font-size: rem(13px); + } +} + +.footerInner { + display: flex; + align-items: center; + justify-content: flex-end; + + @media (max-width: $mantine-breakpoint-sm) { + flex-direction: column; + align-items: center; + justify-content: center; + gap: rem(12px); + } +} + +.footerBranding { + color: var(--docs-hub-fg); + font-weight: 600; + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-hub-brand); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-hub-brand); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } +} diff --git a/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx new file mode 100644 index 000000000..f98d3ee93 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-page-nav.tsx @@ -0,0 +1,70 @@ +import { useMemo } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; +import styles from "./docs.module.css"; + +export default function DocsPageNav() { + const { t } = useTranslation(); + const { pageSlug } = useParams(); + const { treeData, getNodeUrl } = useDocsSurface(); + + const { prev, next } = useMemo(() => { + if (!treeData?.length) { + return { + prev: null as SharedPageTreeNode | null, + next: null as SharedPageTreeNode | null, + }; + } + const flat = flattenTreePreorder(treeData); + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + const index = flat.findIndex((node) => node.slugId === currentSlugId); + return { + prev: index > 0 ? flat[index - 1] : null, + next: index >= 0 && index < flat.length - 1 ? flat[index + 1] : null, + }; + }, [treeData, pageSlug]); + + if (!prev && !next) return null; + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-search-button.tsx b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx new file mode 100644 index 000000000..20fc8e778 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-search-button.tsx @@ -0,0 +1,22 @@ +import { IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { platformModifierLabel } from "@/lib"; +import styles from "./docs.module.css"; + +type DocsSearchButtonProps = { + onClick: () => void; +}; + +export default function DocsSearchButton({ onClick }: DocsSearchButtonProps) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-shell.tsx b/apps/client/src/features/public-space/components/docs/docs-shell.tsx new file mode 100644 index 000000000..1b630644f --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-shell.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { ActionIcon, Drawer, Tooltip } from "@mantine/core"; +import { Link } from "react-router-dom"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconList, IconMenu2 } from "@tabler/icons-react"; +import clsx from "clsx"; +import { + docsMobileSidebarAtom, + docsMobileTocAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { + DocsSurface, + DocsSurfaceProvider, +} from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import DocsSidebarTree from "@/features/public-space/components/docs/docs-sidebar-tree.tsx"; +import DocsToc from "@/features/public-space/components/docs/docs-toc.tsx"; +import DocsEditPage from "@/features/public-space/components/docs/docs-edit-page.tsx"; +import DocsCopyPage from "@/features/public-space/components/docs/docs-copy-page.tsx"; +import DocsSearchButton from "@/features/public-space/components/docs/docs-search-button.tsx"; +import DocsThemeToggle from "@/features/public-space/components/docs/docs-theme-toggle.tsx"; +import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx"; +import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; +import { SearchMobileControl } from "@/features/search/components/search-control.tsx"; +import styles from "./docs.module.css"; + +const MemoizedDocsSidebarTree = React.memo(DocsSidebarTree); + +type DocsShellProps = { + surface: DocsSurface; + onSearchOpen?: () => void; + searchSpotlight?: React.ReactNode; + children: React.ReactNode; +}; + +export default function DocsShell({ + surface, + onSearchOpen, + searchSpotlight, + children, +}: DocsShellProps) { + const { t } = useTranslation(); + const { hasSidebar, siteName, homeUrl, showBranding, showEditPage } = surface; + + const [mobileSidebarOpen, setMobileSidebarOpen] = useAtom( + docsMobileSidebarAtom, + ); + const [mobileTocOpen, setMobileTocOpen] = useAtom(docsMobileTocAtom); + + return ( + +
+ + +
+
+
+ {hasSidebar && ( + + setMobileSidebarOpen((value) => !value)} + aria-label={t("Toggle sidebar")} + aria-expanded={mobileSidebarOpen} + > + + + + )} + + {!hasSidebar && siteName && homeUrl && ( + + {siteName} + + )} +
+ +
+ {onSearchOpen && ( +
+ +
+ )} +
+ +
+ {onSearchOpen && ( + + + + )} + + +
+
+
+ +
+ + +
+
+
+ + + + setMobileTocOpen(true)} + size="md" + aria-label={t("Table of contents")} + > + + + + +
+ {children} + {showBranding && ( + + )} +
+
+ + +
+ + setMobileSidebarOpen(false)} + title={siteName} + size={300} + padding="sm" + > +
+ {hasSidebar && } +
+
+ + setMobileTocOpen(false)} + position="right" + size={300} + padding="md" + > + + {showEditPage && } + + + {searchSpotlight} +
+
+ ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx new file mode 100644 index 000000000..183fae8e9 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-sidebar-tree.tsx @@ -0,0 +1,198 @@ +import { SharedPageTreeNode } from "@/features/share/utils.ts"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useAtom, useSetAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { IconChevronRight } from "@tabler/icons-react"; +import { ActionIcon } from "@mantine/core"; +import { extractPageSlugId } from "@/lib"; +import { + DocTree, + type DocTreeApi, + type RenderRowProps, +} from "@/features/page/tree/components/doc-tree"; +import { + docsMobileSidebarAtom, + openPublicSpaceTreeNodesAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { findAncestorTrail } from "@/features/public-space/utils/docs-tree.ts"; +import styles from "./docs.module.css"; + +export default function DocsSidebarTree() { + const { t } = useTranslation(); + const treeRef = useRef(null); + const { pageSlug } = useParams(); + const { treeData, getNodeUrl } = useDocsSurface(); + const [openTreeNodes, setOpenTreeNodes] = useAtom( + openPublicSpaceTreeNodesAtom, + ); + + // The first root page is the surface home, served at the bare URL. + const firstRootSlugId = treeData?.[0]?.slugId; + + const currentNodeId = pageSlug ? extractPageSlugId(pageSlug) : firstRootSlugId; + + const openIds = useMemo( + () => new Set(Object.keys(openTreeNodes).filter((k) => openTreeNodes[k])), + [openTreeNodes], + ); + + useEffect(() => { + // Auto-open the first level of the tree on initial load. + const root = treeData?.[0]; + if (!root) return; + setOpenTreeNodes((prev) => { + if (prev[root.slugId]) return prev; + const next = { ...prev, [root.slugId]: true }; + for (const child of root.children ?? []) { + next[child.slugId] = true; + } + return next; + }); + }, [treeData, setOpenTreeNodes]); + + // Reveal the current page: expand its ancestor trail (deep links land with + // everything collapsed otherwise) and the page itself when it has children. + useEffect(() => { + if (!currentNodeId || !treeData?.length) return; + const trail = findAncestorTrail(treeData, currentNodeId); + if (trail === null) return; + setOpenTreeNodes((prev) => { + const next = { ...prev }; + let changed = false; + for (const node of [...trail.map((n) => n.slugId), currentNodeId]) { + if (!next[node]) { + next[node] = true; + changed = true; + } + } + return changed ? next : prev; + }); + }, [currentNodeId, treeData, setOpenTreeNodes]); + + useEffect(() => { + if (currentNodeId) { + treeRef.current?.select(currentNodeId, { scrollIntoView: true }); + } + }, [currentNodeId, treeData]); + + const handleToggle = useCallback( + (id: string, isOpen: boolean) => + setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })), + [setOpenTreeNodes], + ); + const getDragLabel = useCallback( + (n: SharedPageTreeNode) => n.name || "untitled", + [], + ); + + const renderRow = useCallback( + (props: RenderRowProps) => ( + + ), + [getNodeUrl], + ); + + if (!treeData?.length) { + return null; + } + + return ( + + readOnly + ref={treeRef} + data={treeData} + openIds={openIds} + selectedId={currentNodeId} + renderRow={renderRow} + indentPerLevel={INDENT_PER_LEVEL} + rowHeight={36} + dynamicRowHeight + rowClassName={styles.treeNodeChrome} + onMove={noopMove} + onToggle={handleToggle} + getDragLabel={getDragLabel} + aria-label={t("Pages")} + /> + ); +} + +// Module-scope noop so it's a stable reference across renders. +const noopMove = () => {}; + +const INDENT_PER_LEVEL = 16; + + +type DocsTreeRowProps = RenderRowProps & { + getNodeUrl: (node: Pick) => string; +}; + +function DocsTreeRow({ + node, + level, + isOpen, + hasChildren, + isSelected, + rowRef, + tabIndex, + treeItemProps, + toggleOpen, + getNodeUrl, +}: DocsTreeRowProps) { + const { t } = useTranslation(); + const setMobileSidebarOpen = useSetAtom(docsMobileSidebarAtom); + + return ( + } + tabIndex={tabIndex} + {...treeItemProps} + data-selected={isSelected || undefined} + data-open-parent={(level === 0 && isOpen && hasChildren) || undefined} + className={styles.treeRow} + to={getNodeUrl(node)} + onClick={() => { + setMobileSidebarOpen(false); + }} + > + {/* One segment per ancestor level; contiguous rows join into a rail. */} + {Array.from({ length: level }, (_, ancestor) => ( + + ))} + {node.icon && ( + + {node.icon} + + )} + {node.name || t("untitled")} + {hasChildren && ( + { + e.preventDefault(); + e.stopPropagation(); + toggleOpen(); + }} + > + + + )} + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx new file mode 100644 index 000000000..db5c23651 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-surface-context.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +// What varies between the public surfaces (/docs and /share) rendered by the +// docs shell; every shell component reads this contract instead of a feature. +export type DocsSurface = { + treeData: SharedPageTreeNode[] | null; + hasSidebar: boolean; + siteName?: string; + homeUrl?: string; + getNodeUrl: (node: Pick) => string; + showBranding: boolean; + showEditPage: boolean; + brandingRef?: string; +}; + +const DocsSurfaceContext = createContext(null); + +export const DocsSurfaceProvider = DocsSurfaceContext.Provider; + +export function useDocsSurface(): DocsSurface { + const surface = useContext(DocsSurfaceContext); + if (!surface) { + throw new Error("useDocsSurface must be used within DocsShell"); + } + return surface; +} diff --git a/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx new file mode 100644 index 000000000..fdc8d6523 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-theme-toggle.tsx @@ -0,0 +1,35 @@ +import { + ActionIcon, + Tooltip, + useComputedColorScheme, + useMantineColorScheme, +} from "@mantine/core"; +import { IconMoon, IconSun } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import styles from "./docs.module.css"; + +export default function DocsThemeToggle() { + const { t } = useTranslation(); + const { setColorScheme } = useMantineColorScheme(); + const computedColorScheme = useComputedColorScheme("light"); + + return ( + + + setColorScheme(computedColorScheme === "light" ? "dark" : "light") + } + aria-label={t("Toggle color scheme")} + > + {computedColorScheme === "light" ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs-toc.tsx b/apps/client/src/features/public-space/components/docs/docs-toc.tsx new file mode 100644 index 000000000..ceb43641f --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs-toc.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from "react"; +import { TextSelection } from "@tiptap/pm/state"; +import { useAtomValue } from "jotai"; +import { useTranslation } from "react-i18next"; +import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; +import { + HeadingLink, + recalculateLinks, +} from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; +import styles from "./docs.module.css"; + +function getHeaderOffset(): number { + const raw = getComputedStyle(document.documentElement).getPropertyValue( + "--docs-header-h", + ); + const parsed = parseInt(raw, 10); + return Number.isNaN(parsed) ? 56 : parsed; +} + +export default function DocsToc() { + const { t } = useTranslation(); + const editor = useAtomValue(readOnlyEditorAtom); + const [links, setLinks] = useState([]); + const [headingDOMNodes, setHeadingDOMNodes] = useState([]); + const [activeElement, setActiveElement] = useState(null); + + const handleUpdate = useCallback(() => { + if (!editor || editor.isDestroyed) return; + const result = recalculateLinks(editor.$nodes("heading")); + setLinks(result.links); + setHeadingDOMNodes(result.nodes); + }, [editor]); + + useEffect(() => { + // "create" repopulates once the editor view mounts after this component. + editor?.on("create", handleUpdate); + editor?.on("update", handleUpdate); + handleUpdate(); + + return () => { + editor?.off("create", handleUpdate); + editor?.off("update", handleUpdate); + }; + }, [editor, handleUpdate]); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveElement(entry.target as HTMLElement); + } + }); + }, + { + rootMargin: `-${getHeaderOffset()}px 0px -85% 0px`, + threshold: 0, + root: null, + }, + ); + + headingDOMNodes.forEach((heading) => observer.observe(heading)); + return () => { + headingDOMNodes.forEach((heading) => observer.unobserve(heading)); + }; + }, [headingDOMNodes]); + + const handleScrollToHeading = (position: number) => { + if (!editor || editor.isDestroyed) return; + const { view } = editor; + + const { node } = view.domAtPos(position); + const element = node as HTMLElement; + const scrollPosition = + element.getBoundingClientRect().top + + window.scrollY - + getHeaderOffset() - + 16; + + window.scrollTo({ top: scrollPosition, behavior: "smooth" }); + + const tr = view.state.tr; + tr.setSelection(new TextSelection(tr.doc.resolve(position))); + view.dispatch(tr); + view.focus(); + }; + + if (!links.length) { + return null; + } + + const minLevel = Math.min(...links.map((link) => link.level)); + const effectiveActive = activeElement ?? links[0]?.element; + + return ( +
+ {t("On this page")} +
+ {links.map((item, idx) => ( + + ))} +
+
+ ); +} diff --git a/apps/client/src/features/public-space/components/docs/docs.module.css b/apps/client/src/features/public-space/components/docs/docs.module.css new file mode 100644 index 000000000..6f008a429 --- /dev/null +++ b/apps/client/src/features/public-space/components/docs/docs.module.css @@ -0,0 +1,902 @@ +/* Design tokens for the public docs surface. Declared on :root (not .root) + * because mobile drawers and the spotlight render in portals outside the shell + * subtree. The accent pair is overridden at runtime by docs-theme.ts. */ +/* light-dark() cannot live in a bare :root block (the transform emits a + * descendant selector that never matches :root), so dark values get their own + * attribute-qualified block; flipping semantics ride Mantine's own vars. */ +:root { + --docs-header-h: 56px; + --docs-sidebar-w: 280px; + --docs-toc-w: 240px; + --docs-site-max: 96rem; + --docs-content-max: 54rem; + --docs-radius: 6px; + + --docs-accent: #2b7af1; + --docs-accent-soft: color-mix(in srgb, var(--docs-accent) 10%, transparent); + + /* Cloudflare-style single-ink model: one foreground for headings, bold, and + * body on a just-off-white page; neither end of the scale is pure. */ + --docs-bg: oklch(99% 0 0); + --docs-fg: oklch(21% 0 0); + --docs-content-fg: var(--docs-fg); + --docs-nav-fg: oklch(47% 0 0); + --docs-muted: var(--mantine-color-dimmed); + --docs-hover: var(--mantine-color-default-hover); + --docs-faint: var(--mantine-color-gray-6); + --docs-border: var(--mantine-color-gray-2); + --docs-header-bg: color-mix(in srgb, var(--docs-bg) 78%, transparent); +} + +:root[data-mantine-color-scheme="dark"] { + --docs-bg: var(--mantine-color-body); + --docs-fg: oklch(90% 0 0); + --docs-nav-fg: oklch(72% 0 0); + --docs-faint: var(--mantine-color-dark-2); + --docs-border: var(--mantine-color-dark-5); +} + +.root { + min-height: 100dvh; + background-color: var(--docs-bg); + color: var(--docs-fg); +} + +/* ---------- Header ---------- */ + +.header { + position: sticky; + top: 0; + z-index: 90; + height: var(--docs-header-h); + padding-inline: rem(20px); + background-color: var(--docs-header-bg); + backdrop-filter: saturate(180%) blur(10px); + -webkit-backdrop-filter: saturate(180%) blur(10px); + border-bottom: 1px solid var(--docs-border); +} + +.headerInner { + max-width: var(--docs-site-max); + margin-inline: auto; + height: 100%; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: rem(16px); +} + +.headerLeft { + display: flex; + align-items: center; + gap: rem(10px); + min-width: 0; +} + +.headerCenter { + display: flex; + justify-content: center; + min-width: 0; +} + +.headerRight { + display: flex; + align-items: center; + justify-content: flex-end; + gap: rem(4px); +} + +.searchSlot { + @media (max-width: $mantine-breakpoint-sm) { + display: none; + } +} + +.mobileOnly { + display: inline-flex; + + @media (min-width: $mantine-breakpoint-sm) { + display: none; + } +} + +/* Plain space name in the header, used only when there is no sidebar to + * carry it. The brand slot is reserved for future org logo/name support. */ +.headerSpaceName { + font-size: rem(14.5px); + font-weight: 600; + letter-spacing: -0.011em; + color: var(--docs-fg); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: var(--docs-radius); + padding: rem(4px) rem(6px); + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } +} + +/* Burger appears only once the sidebar column is collapsed. */ +@media (min-width: 64em) { + .sidebarToggle { + display: none !important; + } +} + +.headerAction { + color: var(--docs-faint); + border-radius: var(--docs-radius); + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + background-color: var(--docs-hover); + } + } +} + +/* ---------- Search ---------- */ + +.searchButton { + display: flex; + align-items: center; + gap: rem(8px); + width: rem(320px); + height: rem(34px); + padding-inline: rem(10px); + border: 1px solid var(--docs-border); + border-radius: rem(8px); + background-color: var(--docs-bg); + color: var(--docs-muted); + font-size: rem(13px); + cursor: pointer; + transition: border-color 120ms ease; + + @media (hover: hover) { + &:hover { + border-color: light-dark( + var(--mantine-color-gray-4), + var(--mantine-color-dark-3) + ); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } + + @media (max-width: 62em) { + width: rem(220px); + } +} + +.searchLabel { + flex: 1; + text-align: left; +} + +.searchKbd { + font-size: rem(11px); + font-weight: 500; + color: var(--docs-faint); + border: 1px solid var(--docs-border); + border-radius: rem(4px); + padding: rem(1px) rem(5px); + line-height: 1.4; +} + +/* Quiet bordered chip in the docs palette instead of Mantine's full-ink default. */ +.copyPageButton { + color: var(--docs-muted); + border-color: var(--docs-border); + background-color: transparent; + font-size: rem(13px); + font-weight: 500; + border-radius: var(--docs-radius); + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + background-color: var(--docs-hover); + } + } +} + +/* Page actions pinned to the article's top-right corner; on small viewports + * they drop into flow above the content so breadcrumbs never run under them. */ +.articleActions { + position: absolute; + top: rem(30px); + right: rem(24px); + display: inline-flex; + align-items: center; + gap: rem(6px); + + @media (max-width: $mantine-breakpoint-sm) { + position: static; + display: flex; + justify-content: flex-end; + margin-bottom: rem(4px); + } +} + +/* Below the rail breakpoint the toc opens as a drawer overlay instead. */ +.tocOverlayControl { + display: inline-flex; + + @media (min-width: 75em) { + display: none; + } +} + +/* ---------- Body grid ---------- */ + +/* Contained site layout: rails anchor the edges of a centered max-width + * container, the article centers itself in the fixed middle track. Hiding a + * rail keeps its track, so toggling never moves the content column. */ +.body { + max-width: var(--docs-site-max); + margin-inline: auto; + display: grid; + grid-template-columns: + var(--docs-sidebar-w) + minmax(0, 1fr) + var(--docs-toc-w); + align-items: start; +} + +.sidebar { + grid-column: 1; + width: var(--docs-sidebar-w); + position: sticky; + top: var(--docs-header-h); + height: calc(100dvh - var(--docs-header-h)); + display: flex; + flex-direction: column; + padding: rem(20px) rem(10px) rem(16px) rem(20px); + opacity: 1; + transform: translateX(0); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.sidebar[data-hidden="true"] { + visibility: hidden; + opacity: 0; + transform: translateX(rem(-8px)); +} + +.sidebarTitle { + display: block; + font-size: rem(14px); + font-weight: 600; + letter-spacing: -0.011em; + color: var(--docs-fg); + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-radius: var(--docs-radius); + padding: rem(5px) rem(8px); + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } +} + +.sidebarDivider { + height: 1px; + background-color: var(--docs-border); + margin: rem(10px) 0 rem(14px); + flex-shrink: 0; +} + +.sidebarScroll { + flex: 1; + min-height: 0; +} + +.main { + grid-column: 2; + min-width: 0; +} + +.article { + max-width: var(--docs-content-max); + margin-inline: auto; + padding: rem(36px) rem(32px) rem(72px); + position: relative; +} + +.toc { + grid-column: 3; + width: var(--docs-toc-w); + position: sticky; + top: var(--docs-header-h); + max-height: calc(100dvh - var(--docs-header-h)); + overflow-y: auto; + scrollbar-width: thin; + padding: rem(36px) rem(16px) rem(24px) rem(4px); +} + +@media (max-width: 75em) { + .body { + grid-template-columns: var(--docs-sidebar-w) minmax(0, 1fr); + } + + .toc { + display: none; + } +} + +/* Below 64em the sidebar collapses into the burger drawer. */ +@media (max-width: 64em) { + .body { + display: block; + } + + .sidebar { + display: none; + } +} + +@media (max-width: $mantine-breakpoint-sm) { + .article { + padding: rem(24px) rem(20px) rem(56px); + } +} + +@media (prefers-reduced-motion: reduce) { + .body, + .sidebar, + .toc, + .searchButton { + transition: none; + } +} + +/* ---------- Sidebar tree ---------- */ + +/* Applied through DocTree's rowClassName onto the engine wrapper that carries + * data-selected. Class doubled to outrank the engine's own module styles. */ +.treeNodeChrome.treeNodeChrome { + border-radius: var(--docs-radius); + color: var(--docs-nav-fg); + + @media (hover: hover) { + &:hover { + background-color: var(--docs-hover); + color: var(--docs-fg); + } + } +} + +.treeNodeChrome.treeNodeChrome[data-selected="true"] { + background-color: var(--docs-accent-soft); +} + +.treeRow { + position: relative; + display: flex; + align-items: flex-start; + gap: rem(8px); + width: 100%; + min-width: 0; + min-height: rem(32px); + padding: rem(6px) rem(4px) rem(6px) rem(8px); + text-decoration: none; + color: inherit; + font-size: rem(14px); + font-weight: 400; + line-height: 1.45; + border-radius: var(--docs-radius); + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + } +} + +/* Expanded parents read as section headers, Cloudflare-style. */ +.treeRow[data-open-parent="true"] { + color: var(--docs-fg); + font-weight: 500; +} + +.treeRow[data-selected="true"] { + color: var(--docs-accent); + font-weight: 500; +} + +.treeIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: rem(18px); + font-size: rem(14px); + line-height: 1; + flex-shrink: 0; + margin-top: rem(2px); +} + +/* Names wrap instead of truncating: the sidebar has a fixed width, so an + * ellipsis would permanently hide the tail of long titles. */ +.treeText { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +/* Nesting rail: each row draws its ancestors' segments in the indent gutter; + * the -2px bleed covers the engine's row padding so segments connect. */ +.treeGuide { + position: absolute; + top: 0; + bottom: rem(-2px); + width: 1px; + background-color: var(--docs-border); + pointer-events: none; +} + +.treeChevron { + flex-shrink: 0; + color: var(--docs-faint); + transition: transform 140ms ease; +} + +.treeChevron[data-open="true"] { + transform: rotate(90deg); +} + +@media (prefers-reduced-motion: reduce) { + .treeChevron { + transition: none; + } +} + +/* ---------- Table of contents ---------- */ + +.tocLabel { + display: block; + font-size: rem(11px); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--docs-muted); + margin-bottom: rem(10px); +} + +.tocList { + border-left: 1px solid var(--docs-border); +} + +.tocLink { + display: block; + width: 100%; + text-align: left; + background: none; + border: 0; + border-left: 2px solid transparent; + margin-left: -1px; + padding: rem(4px) rem(8px) rem(4px) rem(11px); + font-size: rem(13px); + line-height: 1.45; + color: var(--docs-muted); + cursor: pointer; + overflow-wrap: break-word; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: -2px; + border-radius: rem(2px); + } +} + +.tocLink[data-active="true"] { + color: var(--docs-accent); + border-left-color: var(--docs-accent); + font-weight: 500; +} + +/* ---------- Edit page (signed-in visitors) ---------- */ + +.editPageLink { + display: flex; + align-items: center; + gap: rem(6px); + margin-top: rem(16px); + padding-top: rem(14px); + border-top: 1px solid var(--docs-border); + font-size: rem(13px); + color: var(--docs-muted); + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +/* ---------- Sidebar branding experiments (GitBook card / ReadMe line) ---------- */ + +/* ---------- Footer branding ---------- */ + +.footer { + margin-top: rem(40px); + padding-top: rem(16px); + border-top: 1px solid var(--docs-border); +} + +.footerBranding { + font-size: rem(14px); + color: var(--docs-muted); + text-decoration: none; + + @media (hover: hover) { + &:hover { + color: var(--docs-fg); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + border-radius: rem(2px); + } +} + +/* ---------- Breadcrumbs ---------- */ + +.breadcrumbs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: rem(4px); + font-size: rem(13px); + color: var(--docs-muted); + margin-bottom: rem(6px); + /* keep long trails clear of the pinned page actions */ + padding-right: rem(160px); + + @media (max-width: $mantine-breakpoint-sm) { + padding-right: 0; + } +} + +.crumbLink { + color: var(--docs-muted); + text-decoration: none; + border-radius: rem(4px); + padding: rem(1px) rem(3px); + max-width: rem(220px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 0; + } +} + +.crumbSeparator { + color: light-dark( + var(--mantine-color-gray-4), + var(--mantine-color-dark-3) + ); + user-select: none; +} + +.crumbEllipsis { + color: var(--docs-muted); + border: 0; + background: none; + cursor: pointer; + border-radius: rem(4px); + padding: rem(1px) rem(4px); + + @media (hover: hover) { + &:hover { + color: var(--docs-accent); + background-color: var(--docs-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 0; + } +} + +/* ---------- Byline ---------- */ + +/* Pulled up into the title's own bottom margin so it reads as one block. */ +.byline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: rem(8px); + margin-top: rem(-16px); + margin-bottom: rem(28px); + font-size: rem(13px); + color: var(--docs-muted); +} + +.bylineAuthor { + display: inline-flex; + align-items: center; + gap: rem(6px); +} + +.bylineDot { + color: var(--docs-faint); + user-select: none; +} + +/* ---------- Prev / next ---------- */ + +.pageNav { + display: grid; + grid-template-columns: 1fr 1fr; + gap: rem(12px); + margin-top: rem(48px); +} + +.pageNavCard { + display: flex; + flex-direction: column; + gap: rem(4px); + padding: rem(12px) rem(16px); + border: 1px solid var(--docs-border); + border-radius: rem(10px); + text-decoration: none; + min-width: 0; + transition: border-color 120ms ease; + + @media (hover: hover) { + &:hover { + border-color: var(--docs-accent); + } + + &:hover .pageNavTitle { + color: var(--docs-accent); + } + } + + &:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 1px; + } +} + +.pageNavCard[data-direction="next"] { + grid-column: 2; + align-items: flex-end; + text-align: right; +} + +.pageNavLabel { + display: inline-flex; + align-items: center; + gap: rem(4px); + font-size: rem(12px); + color: var(--docs-muted); +} + +.pageNavTitle { + font-size: rem(14px); + font-weight: 500; + color: var(--docs-fg); + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: color 120ms ease; +} + +@media (max-width: $mantine-breakpoint-sm) { + .pageNav { + grid-template-columns: 1fr; + } + + .pageNavCard[data-direction="next"] { + grid-column: auto; + } +} + +/* ---------- Content ---------- */ + +/* Reading typography: body copy softens to a blue-gray with relaxed leading + * while headings and bold keep full contrast. Class doubled to outrank the + * shared .public-typography metrics regardless of stylesheet order. */ +.root.root :global(.ProseMirror) { + color: var(--docs-content-fg); + line-height: 1.75; + letter-spacing: normal; + /* The article owns horizontal spacing here; the editor's 3rem gutter would + * misalign content with the breadcrumb, and its own background bands + * against the off-white page. */ + padding-left: 0; + padding-right: 0; + background-color: transparent; +} + +/* Wide columns bleed into the editor gutter that no longer exists here. */ +.root.root :global(div[data-type="columns"][data-width-mode="wide"]) { + margin-left: 0; + margin-right: 0; + width: 100%; +} + +/* One ink by inheritance: Mantine's baseline gives headings an explicit + * color, which forks them from the body. Neutralize instead of re-declaring, + * so changing the single content foreground repaints all text. */ +.root.root :global(.ProseMirror) h1, +.root.root :global(.ProseMirror) h2, +.root.root :global(.ProseMirror) h3, +.root.root :global(.ProseMirror) h4, +.root.root :global(.ProseMirror) h5, +.root.root :global(.ProseMirror) h6, +.root.root :global(.ProseMirror) strong { + color: inherit; +} + +/* Modest semibold heading scale (Cloudflare-style); class doubled to outrank + * the shared editor and .public-typography rules. */ +.root.root :global(.ProseMirror) h1 { + font-size: 2.1875rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.root.root :global(.ProseMirror) h2 { + font-size: 1.3rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.4; +} + +.root.root :global(.ProseMirror) h3 { + font-size: 1.1rem; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.45; +} + +.root.root :global(.ProseMirror) h4, +.root.root :global(.ProseMirror) h5, +.root.root :global(.ProseMirror) h6 { + font-size: 1rem; + font-weight: 600; + line-height: 1.5; +} + +.root.root :global(.ProseMirror) strong { + font-weight: 600; +} + +.root.root :global(.page-title .ProseMirror) h1 { + font-size: 2.1875rem; + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.25; +} + +.root :global(.ProseMirror) a { + color: var(--docs-accent); +} + +/* Internal page links (mentions, subpages lists) follow the accent like any + * other link; the app skin pins them to ink with !important, hence the + * counter-!important, and the underline tint softens to match. */ +.root :global(.ProseMirror) a[class*="pageMentionLink"] { + color: var(--docs-accent) !important; +} + +.root :global(.ProseMirror) [class*="pageMentionText"] { + border-bottom-color: color-mix(in srgb, var(--docs-accent) 40%, transparent); +} + +.root :global(.ProseMirror) h1, +.root :global(.ProseMirror) h2, +.root :global(.ProseMirror) h3, +.root :global(.ProseMirror) h4 { + scroll-margin-top: calc(var(--docs-header-h) + rem(16px)); +} + +/* ---------- Content tables ---------- */ + +/* The wrapper carries the rounded outer border so border-collapse never + * fights border-radius; its overflow-x clips the corners. */ +.root.root :global(.ProseMirror .tableWrapper) { + border: 1px solid var(--docs-border); + border-radius: rem(10px); +} + +.root.root :global(.ProseMirror table td), +.root.root :global(.ProseMirror table th) { + border: 0; + border-bottom: 1px solid var(--docs-border); + border-right: 1px solid var(--docs-border); + padding: rem(10px) rem(14px); +} + +.root.root :global(.ProseMirror table :is(td, th):last-child) { + border-right: 0; +} + +.root.root :global(.ProseMirror table tr:last-child td), +.root.root :global(.ProseMirror table tr:last-child th) { + border-bottom: 0; +} + +.root.root :global(.ProseMirror table th) { + background-color: var(--docs-hover); + color: var(--docs-fg); + font-weight: 600; +} + +/* Round the corner cells too for the no-overflow (pinned header) variant, + * where the wrapper does not clip. */ +.root.root :global(.ProseMirror table tr:first-child :is(th, td):first-child) { + border-top-left-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:first-child :is(th, td):last-child) { + border-top-right-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:last-child :is(th, td):first-child) { + border-bottom-left-radius: rem(9px); +} + +.root.root :global(.ProseMirror table tr:last-child :is(th, td):last-child) { + border-bottom-right-radius: rem(9px); +} + +.emptyState { + padding-top: rem(96px); + text-align: center; + color: var(--docs-muted); +} + +/* ---------- Mobile drawers ---------- */ + +.drawerTree { + height: calc(100dvh - rem(60px)); + display: flex; + flex-direction: column; +} diff --git a/apps/client/src/features/public-space/components/public-space-layout.tsx b/apps/client/src/features/public-space/components/public-space-layout.tsx new file mode 100644 index 000000000..2bd01b20a --- /dev/null +++ b/apps/client/src/features/public-space/components/public-space-layout.tsx @@ -0,0 +1,69 @@ +import "@fontsource-variable/inter"; +import "@/styles/public-typography.css"; +import { useEffect, useMemo } from "react"; +import { Outlet, useParams } from "react-router-dom"; +import { useSetAtom } from "jotai"; +import { usePublicSpaceTreeQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { buildSharedPageTree } from "@/features/share/utils.ts"; +import { + publicSpaceTreeAtom, + publicSpaceTreeDataAtom, +} from "@/features/public-space/atoms/public-space-atoms.ts"; +import { useDocsAccent } from "@/features/public-space/theme/docs-theme.ts"; +import DocsShell from "@/features/public-space/components/docs/docs-shell.tsx"; +import { DocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { PublicSpaceSearchSpotlight } from "@/features/search/components/public-space-search-spotlight.tsx"; +import { publicSpaceSearchSpotlight } from "@/features/search/constants"; + +export default function PublicSpaceLayout() { + const { spaceSlug } = useParams(); + const { data } = usePublicSpaceTreeQuery(spaceSlug); + + useDocsAccent(data?.appearance); + + const setPublicSpaceTree = useSetAtom(publicSpaceTreeAtom); + const setPublicSpaceTreeData = useSetAtom(publicSpaceTreeDataAtom); + + const treeData = useMemo(() => { + if (!data?.pageTree) return null; + return buildSharedPageTree(data.pageTree); + }, [data?.pageTree]); + + useEffect(() => { + setPublicSpaceTree(data || null); + setPublicSpaceTreeData(treeData); + }, [data, treeData, setPublicSpaceTree, setPublicSpaceTreeData]); + + const surface = useMemo(() => { + const homeUrl = buildPublicSpaceUrl({ spaceSlug }); + // The first root page is the space home, served at the bare space URL. + const firstRootSlugId = treeData?.[0]?.slugId; + return { + treeData, + hasSidebar: (data?.pageTree?.length ?? 0) > 1, + siteName: data?.space?.name, + homeUrl, + getNodeUrl: (node) => + node.slugId === firstRootSlugId + ? homeUrl + : buildPublicSpaceUrl({ + spaceSlug, + pageSlugId: node.slugId, + pageTitle: node.name, + }), + showBranding: Boolean(data), + showEditPage: true, + }; + }, [data, treeData, spaceSlug]); + + return ( + } + > + + + ); +} diff --git a/apps/client/src/features/public-space/components/publish-space-settings.tsx b/apps/client/src/features/public-space/components/publish-space-settings.tsx new file mode 100644 index 000000000..332ebd373 --- /dev/null +++ b/apps/client/src/features/public-space/components/publish-space-settings.tsx @@ -0,0 +1,283 @@ +import { ActionIcon, Group, Text, Switch, TextInput } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { IPublicSpace } from "@/features/public-space/types/public-space.types.ts"; +import { + usePublicSpaceForSpaceQuery, + usePublishSpaceMutation, +} from "@/features/public-space/queries/public-space-query.ts"; +import { getAppUrl } from "@/lib/config.ts"; +import CopyTextButton from "@/components/common/copy.tsx"; +import AppearanceSettings from "@/features/public-space/components/appearance-settings.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; + +type PublishSpaceSettingsProps = { + space: ISpace; +}; + +export default function PublishSpaceSettings({ + space, +}: PublishSpaceSettingsProps) { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + + const { data: publicSpace } = usePublicSpaceForSpaceQuery( + allowPublicSpaces ? space?.id : undefined, + ); + const publishMutation = usePublishSpaceMutation(); + + const [published, setPublished] = useState(false); + const [searchIndexing, setSearchIndexing] = useState(false); + const [bylineAuthor, setBylineAuthor] = useState(false); + const [bylineUpdatedAt, setBylineUpdatedAt] = useState(true); + const [directoryListed, setDirectoryListed] = useState(false); + + const workspaceDirectoryEnabled = + workspace?.settings?.publicSpaces?.directory === true; + + const syncFromPublicSpace = (state?: IPublicSpace | null) => { + const byline = state?.settings?.byline; + setPublished(state?.enabled === true); + setSearchIndexing(state?.searchIndexing === true); + setBylineAuthor(byline?.author === true); + setBylineUpdatedAt(byline?.updatedAt !== false); + setDirectoryListed(state?.settings?.directory === true); + }; + + useEffect(() => { + syncFromPublicSpace(publicSpace); + }, [publicSpace]); + + if (!allowPublicSpaces || !space) { + return null; + } + + const publicUrl = `${getAppUrl()}/docs/${space.slug}`; + + const applyPublish = async (enabled: boolean) => { + try { + const result = await publishMutation.mutateAsync({ + spaceId: space.id, + enabled, + }); + syncFromPublicSpace(result); + } catch { + // error handled by mutation + } + }; + + const handlePublishChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + if (!value) { + applyPublish(false); + return; + } + + modals.openConfirmModal({ + title: t("Publish space to the web"), + children: ( + + {t( + "Anyone on the internet will be able to read every page in this space, except restricted pages. Are you sure?", + )} + + ), + centered: true, + labels: { confirm: t("Publish"), cancel: t("Cancel") }, + onConfirm: () => applyPublish(true), + }); + }; + + const handleIndexingChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + searchIndexing: value, + }); + setSearchIndexing(value); + } catch { + // error handled by mutation + } + }; + + const handleBylineAuthorChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + bylineAuthor: value, + }); + setBylineAuthor(value); + } catch { + // error handled by mutation + } + }; + + const handleBylineUpdatedAtChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + bylineUpdatedAt: value, + }); + setBylineUpdatedAt(value); + } catch { + // error handled by mutation + } + }; + + const handleDirectoryChange = async ( + event: React.ChangeEvent, + ) => { + const value = event.currentTarget.checked; + try { + await publishMutation.mutateAsync({ + spaceId: space.id, + enabled: true, + directory: value, + }); + setDirectoryListed(value); + } catch { + // error handled by mutation + } + }; + + return ( +
+ +
+ {t("Publish space to the web")} + + {t("Make this space publicly readable by anyone on the internet.")} + +
+ +
+ + {published && ( + <> + +
+ {t("Allow search engines to index")} + + {t( + "Let public pages in this space appear in search engine results.", + )} + +
+ +
+ + +
+ {t("Show page author")} + + {t("Display the page creator's name on public pages.")} + +
+ +
+ + +
+ {t("Show last updated")} + + {t("Display when each page was last updated.")} + +
+ +
+ + {workspaceDirectoryEnabled && ( + +
+ {t("Show in public directory")} + + {t("List this space in the public directory at /docs.")} + +
+ +
+ )} + + + } + aria-label={t("Public space link")} + rightSection={ + + } + /> + + + + + + + {t("Renaming the space slug will break public links.")} + + + + + )} +
+ ); +} diff --git a/apps/client/src/features/public-space/components/published-spaces-list.tsx b/apps/client/src/features/public-space/components/published-spaces-list.tsx new file mode 100644 index 000000000..a4aec36a0 --- /dev/null +++ b/apps/client/src/features/public-space/components/published-spaces-list.tsx @@ -0,0 +1,210 @@ +import { Table, Group, Text, Anchor, Menu, ActionIcon } from "@mantine/core"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { modals } from "@mantine/modals"; +import { notifications } from "@mantine/notifications"; +import { + IconCopy, + IconDots, + IconExternalLink, + IconWorld, + IconWorldOff, +} from "@tabler/icons-react"; +import Paginate from "@/components/common/paginate.tsx"; +import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; +import { + usePublishedSpacesQuery, + usePublishSpaceMutation, +} from "@/features/public-space/queries/public-space-query.ts"; +import { IPublishedSpaceItem } from "@/features/public-space/types/public-space.types.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getAppUrl, getSpaceUrl } from "@/lib/config.ts"; +import { useClipboard } from "@/hooks/use-clipboard"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import { EmptyState } from "@/components/ui/empty-state.tsx"; +import rowClasses from "@/components/ui/clickable-table-row.module.css"; + +export default function PublishedSpacesList() { + const { t } = useTranslation(); + const { cursor, goNext, goPrev } = useCursorPaginate(); + const { data, isLoading } = usePublishedSpacesQuery({ cursor }); + const locale = useDateFnsLocale(); + + if (!isLoading && data?.items.length === 0) { + return ; + } + + return ( + <> + + + + + {t("Space")} + {t("Published by")} + {t("Published at")} + + + + + {data?.items.map((item: IPublishedSpaceItem) => ( + + + + + + + {item.space.name} + + + + + + + + + {item.creator?.name} + + + + + + {formatLocalized( + item.createdAt, + "MMM dd, yyyy", + "PP", + locale, + )} + + + + + + + ))} + +
+
+ + {data?.items.length > 0 && ( + goNext(data?.meta?.nextCursor)} + onPrev={goPrev} + /> + )} + + ); +} + +function PublishedSpaceActionMenu({ item }: { item: IPublishedSpaceItem }) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const clipboard = useClipboard(); + const publishMutation = usePublishSpaceMutation(); + + const publicPath = buildPublicSpaceUrl({ spaceSlug: item.space.slug }); + + const copyLink = () => { + clipboard.copy(`${getAppUrl()}${publicPath}`); + notifications.show({ message: t("Link copied") }); + }; + + const onUnpublish = async () => { + try { + await publishMutation.mutateAsync({ + spaceId: item.spaceId, + enabled: false, + }); + } catch { + // error handled by mutation + } + }; + + const openUnpublishModal = () => + modals.openConfirmModal({ + title: t("Unpublish space"), + children: ( + + {t( + "This space will no longer be publicly accessible. Are you sure?", + )} + + ), + centered: true, + labels: { confirm: t("Unpublish"), cancel: t("Cancel") }, + confirmProps: { color: "red" }, + onConfirm: onUnpublish, + }); + + return ( + + + + + + + + + }> + {t("Copy link")} + + + navigate(getSpaceUrl(item.space.slug))} + leftSection={} + > + {t("Open space")} + + + } + disabled={item.space?.userRole !== "admin"} + > + {t("Unpublish")} + + + + ); +} diff --git a/apps/client/src/features/public-space/components/space-public-notice.tsx b/apps/client/src/features/public-space/components/space-public-notice.tsx new file mode 100644 index 000000000..5655995f0 --- /dev/null +++ b/apps/client/src/features/public-space/components/space-public-notice.tsx @@ -0,0 +1,47 @@ +import { Alert, Anchor, Group, Text } from "@mantine/core"; +import { IconExternalLink, IconWorld } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { ISpace } from "@/features/space/types/space.types.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { isBetaPublicSpaces } from "@/lib/config.ts"; + +type SpacePublicNoticeProps = { + space: ISpace; +}; + +export default function SpacePublicNotice({ space }: SpacePublicNoticeProps) { + const { t } = useTranslation(); + + if (!isBetaPublicSpaces() || !space?.isPublished) { + return null; + } + + return ( + } + title={t("This space is public")} + mb="lg" + > + + + {t( + "Anyone on the internet can read the pages in this space, except restricted pages.", + )} + + + {t("Open public site")} + + + + + ); +} diff --git a/apps/client/src/features/public-space/hooks/use-authenticated-user.ts b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts new file mode 100644 index 000000000..644619b26 --- /dev/null +++ b/apps/client/src/features/public-space/hooks/use-authenticated-user.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { getMyInfo } from "@/features/user/services/user-service"; +import { ICurrentUser } from "@/features/user/types/user.types"; + +/** Probes login state from public surfaces; the /docs 401 exemption keeps anonymous visitors off the login redirect. */ +export function useAuthenticatedUser(enabled = true) { + return useQuery({ + queryKey: ["currentUser"], + queryFn: getMyInfo, + retry: false, + staleTime: 5 * 60 * 1000, + enabled, + }); +} diff --git a/apps/client/src/features/public-space/hooks/use-docs-current-page.ts b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts new file mode 100644 index 000000000..eb799ee0e --- /dev/null +++ b/apps/client/src/features/public-space/hooks/use-docs-current-page.ts @@ -0,0 +1,23 @@ +import { useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useDocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { flattenTreePreorder } from "@/features/public-space/utils/docs-tree.ts"; +import { extractPageSlugId } from "@/lib"; +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +export function useDocsCurrentPage(): SharedPageTreeNode | null { + const { pageSlug } = useParams(); + const { treeData } = useDocsSurface(); + + return useMemo(() => { + if (!treeData?.length) return null; + const currentSlugId = pageSlug + ? extractPageSlugId(pageSlug) + : treeData[0]?.slugId; + return ( + flattenTreePreorder(treeData).find( + (node) => node.slugId === currentSlugId, + ) ?? null + ); + }, [treeData, pageSlug]); +} diff --git a/apps/client/src/features/public-space/queries/public-space-query.ts b/apps/client/src/features/public-space/queries/public-space-query.ts new file mode 100644 index 000000000..49cba8cc6 --- /dev/null +++ b/apps/client/src/features/public-space/queries/public-space-query.ts @@ -0,0 +1,109 @@ +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, + UseQueryResult, +} from "@tanstack/react-query"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { + getPublicSpaceDirectory, + getPublicSpaceForSpace, + getPublicSpacePage, + getPublicSpaceTree, + getPublishedSpaces, + publishSpace, +} from "@/features/public-space/services/public-space-service.ts"; +import { + IPublicSpace, + IPublicSpaceDirectory, + IPublicSpacePage, + IPublicSpaceTree, + IPublishedSpaceItem, + IPublishSpace, +} from "@/features/public-space/types/public-space.types.ts"; +import { IPagination, QueryParams } from "@/lib/types.ts"; + +export function usePublicSpaceTreeQuery( + spaceSlug: string, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-tree", spaceSlug], + queryFn: () => getPublicSpaceTree(spaceSlug), + enabled: !!spaceSlug, + placeholderData: keepPreviousData, + staleTime: 60 * 60 * 1000, + }); +} + +export function usePublicSpacePageQuery(params: { + spaceSlug: string; + pageSlugId?: string; + contentless?: boolean; +}): UseQueryResult { + return useQuery({ + queryKey: ["public-space-page", params], + queryFn: () => getPublicSpacePage(params), + enabled: !!params.spaceSlug, + }); +} + +export function usePublicSpaceDirectoryQuery(): UseQueryResult< + IPublicSpaceDirectory, + Error +> { + return useQuery({ + queryKey: ["public-space-directory"], + queryFn: () => getPublicSpaceDirectory(), + }); +} + +export function usePublicSpaceForSpaceQuery( + spaceId: string, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-for-space", spaceId], + queryFn: () => getPublicSpaceForSpace(spaceId), + enabled: !!spaceId, + staleTime: 60 * 1000, + retry: false, + }); +} + +export function usePublishedSpacesQuery( + params?: QueryParams, +): UseQueryResult, Error> { + return useQuery({ + queryKey: ["published-spaces", params], + queryFn: () => getPublishedSpaces(params), + placeholderData: keepPreviousData, + }); +} + +export function usePublishSpaceMutation() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => publishSpace(data), + onSuccess: () => { + queryClient.invalidateQueries({ + predicate: (item) => + [ + "public-space-for-space", + "published-spaces", + "space", + "spaces", + ].includes(item.queryKey[0] as string), + }); + }, + onError: (error) => { + notifications.show({ + message: + error?.["response"]?.data?.message || t("Failed to update space"), + color: "red", + }); + }, + }); +} diff --git a/apps/client/src/features/public-space/services/public-space-service.ts b/apps/client/src/features/public-space/services/public-space-service.ts new file mode 100644 index 000000000..77899650d --- /dev/null +++ b/apps/client/src/features/public-space/services/public-space-service.ts @@ -0,0 +1,73 @@ +import api from "@/lib/api-client"; +import { IPagination, QueryParams } from "@/lib/types.ts"; +import { + IPublicSpace, + IPublicSpaceDirectory, + IPublicSpaceInfo, + IPublicSpacePage, + IPublicSpaceTree, + IPublishedSpaceItem, + IPublishSpace, +} from "@/features/public-space/types/public-space.types.ts"; + +export async function getPublishedSpaces( + params?: QueryParams, +): Promise> { + const req = await api.post>( + "/public-spaces", + params, + ); + return req.data; +} + +export async function getPublicSpaceInfo( + spaceSlug: string, +): Promise { + const req = await api.post("/public-spaces/info", { + spaceSlug, + }); + return req.data; +} + +export async function getPublicSpaceTree( + spaceSlug: string, +): Promise { + const req = await api.post("/public-spaces/tree", { + spaceSlug, + }); + return req.data; +} + +export async function getPublicSpacePage(params: { + spaceSlug: string; + pageSlugId?: string; + contentless?: boolean; +}): Promise { + const req = await api.post( + "/public-spaces/page-info", + params, + ); + return req.data; +} + +export async function getPublicSpaceDirectory(): Promise { + const req = await api.post( + "/public-spaces/directory", + {}, + ); + return req.data; +} + +export async function getPublicSpaceForSpace( + spaceId: string, +): Promise { + const req = await api.post("/public-spaces/for-space", { + spaceId, + }); + return req.data; +} + +export async function publishSpace(data: IPublishSpace): Promise { + const req = await api.post("/public-spaces/publish", data); + return req.data; +} diff --git a/apps/client/src/features/public-space/theme/docs-theme.ts b/apps/client/src/features/public-space/theme/docs-theme.ts new file mode 100644 index 000000000..4e9368335 --- /dev/null +++ b/apps/client/src/features/public-space/theme/docs-theme.ts @@ -0,0 +1,91 @@ +import { useEffect } from "react"; +import { useComputedColorScheme } from "@mantine/core"; +import { IPublicSpaceAppearance } from "@/features/public-space/types/public-space.types.ts"; + +export type DocsThemePreset = { + id: string; + nameKey: string; + light: string; + dark: string; +}; + +export const DOCS_THEME_PRESETS: DocsThemePreset[] = [ + { id: "default", nameKey: "Default", light: "#2b7af1", dark: "#6ea6f6" }, + { id: "forest", nameKey: "Forest", light: "#0f766e", dark: "#2dd4bf" }, + { id: "violet", nameKey: "Violet", light: "#6d28d9", dark: "#a78bfa" }, + { id: "ember", nameKey: "Ember", light: "#c2410c", dark: "#fb923c" }, + { id: "rose", nameKey: "Rose", light: "#be123c", dark: "#fb7185" }, +]; + +export const DEFAULT_DOCS_PRESET = DOCS_THEME_PRESETS[0]; + +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; + +export function isValidDocsColor(value: unknown): value is string { + return typeof value === "string" && HEX_COLOR_REGEX.test(value); +} + +export function resolveDocsAccent( + appearance: IPublicSpaceAppearance | undefined, + scheme: "light" | "dark", +): string { + const custom = + scheme === "dark" + ? appearance?.primaryColorDark + : appearance?.primaryColorLight; + if (isValidDocsColor(custom)) return custom; + return scheme === "dark" + ? DEFAULT_DOCS_PRESET.dark + : DEFAULT_DOCS_PRESET.light; +} + +export function matchDocsPreset( + appearance: IPublicSpaceAppearance | undefined, +): DocsThemePreset | null { + const light = appearance?.primaryColorLight; + const dark = appearance?.primaryColorDark; + if (!light && !dark) return DEFAULT_DOCS_PRESET; + return ( + DOCS_THEME_PRESETS.find( + (preset) => + preset.light.toLowerCase() === light?.toLowerCase() && + preset.dark.toLowerCase() === dark?.toLowerCase(), + ) ?? null + ); +} + +// Set on documentElement (not the shell root) so portaled Mantine surfaces on +// /docs routes (spotlight, drawers) follow the space accent too. +const ACCENT_VARIABLES = (accent: string): Record => ({ + "--docs-accent": accent, + "--docs-accent-soft": `color-mix(in srgb, ${accent} 10%, transparent)`, + "--mantine-primary-color-filled": accent, + "--mantine-primary-color-filled-hover": `color-mix(in srgb, ${accent} 85%, black)`, + "--mantine-primary-color-light": `color-mix(in srgb, ${accent} 10%, transparent)`, + "--mantine-primary-color-light-hover": `color-mix(in srgb, ${accent} 15%, transparent)`, + "--mantine-primary-color-light-color": accent, + "--mantine-color-anchor": accent, +}); + +export function useDocsAccent(appearance: IPublicSpaceAppearance | undefined) { + const scheme = useComputedColorScheme("light"); + const light = appearance?.primaryColorLight; + const dark = appearance?.primaryColorDark; + + useEffect(() => { + const accent = resolveDocsAccent( + { primaryColorLight: light, primaryColorDark: dark }, + scheme, + ); + const root = document.documentElement; + const variables = ACCENT_VARIABLES(accent); + for (const [name, value] of Object.entries(variables)) { + root.style.setProperty(name, value); + } + return () => { + for (const name of Object.keys(variables)) { + root.style.removeProperty(name); + } + }; + }, [light, dark, scheme]); +} diff --git a/apps/client/src/features/public-space/types/public-space.types.ts b/apps/client/src/features/public-space/types/public-space.types.ts new file mode 100644 index 000000000..55fabe9af --- /dev/null +++ b/apps/client/src/features/public-space/types/public-space.types.ts @@ -0,0 +1,105 @@ +import { IPage } from "@/features/page/types/page.types.ts"; + +export interface IPublicSpaceSummary { + id: string; + name: string; + slug: string; + description?: string; + logo?: string; +} + +export interface IPublicSpaceAppearance { + primaryColorLight?: string; + primaryColorDark?: string; +} + +export interface IPublicSpaceByline { + author: boolean; + updatedAt: boolean; +} + +export interface IPublicSpaceInfo { + space: IPublicSpaceSummary; + searchIndexing: boolean; + appearance?: IPublicSpaceAppearance; + features?: string[]; +} + +export interface IPublicSpaceTree { + space: IPublicSpaceSummary; + pageTree: Partial; + appearance?: IPublicSpaceAppearance; + features?: string[]; +} + +export interface IPublicSpacePage { + page: IPage | null; + space: IPublicSpaceSummary; + searchIndexing: boolean; + appearance?: IPublicSpaceAppearance; + byline?: IPublicSpaceByline; + features?: string[]; +} + +export interface IPublicSpace { + id: string; + spaceId: string; + workspaceId: string; + enabled: boolean; + searchIndexing: boolean; + settings?: { + appearance?: IPublicSpaceAppearance; + byline?: Partial; + directory?: boolean; + } | null; + creatorId?: string; + createdAt: string; + updatedAt: string; +} + +export interface IPublishedSpaceItem { + id: string; + spaceId: string; + workspaceId: string; + searchIndexing: boolean; + settings?: IPublicSpace["settings"]; + createdAt: string; + updatedAt: string; + space: { + id: string; + name: string; + slug: string; + logo?: string; + userRole: string; + }; + creator: { + id: string; + name: string; + avatarUrl: string | null; + }; +} + +export interface IPublishSpace { + spaceId: string; + enabled: boolean; + searchIndexing?: boolean; + appearance?: { + primaryColorLight?: string | null; + primaryColorDark?: string | null; + }; + bylineAuthor?: boolean; + bylineUpdatedAt?: boolean; + directory?: boolean; +} + +export interface IPublicSpaceDirectoryEntry { + name: string; + slug: string; + description?: string; + logo?: string; +} + +export interface IPublicSpaceDirectory { + spaces: IPublicSpaceDirectoryEntry[]; + features?: string[]; +} diff --git a/apps/client/src/features/public-space/utils/docs-tree.ts b/apps/client/src/features/public-space/utils/docs-tree.ts new file mode 100644 index 000000000..22ad8ffb3 --- /dev/null +++ b/apps/client/src/features/public-space/utils/docs-tree.ts @@ -0,0 +1,39 @@ +import { SharedPageTreeNode } from "@/features/share/utils.ts"; + +// Preorder walk of the whole tree, matching the sidebar's visual order. Drives +// prev/next navigation independently of which nodes are expanded. +export function flattenTreePreorder( + nodes: SharedPageTreeNode[], +): SharedPageTreeNode[] { + const out: SharedPageTreeNode[] = []; + const walk = (list: SharedPageTreeNode[]) => { + for (const node of list) { + out.push(node); + if (node.children?.length) walk(node.children); + } + }; + walk(nodes); + return out; +} + +// Ancestors of the node with the given slugId, root-first, excluding the node +// itself. Null when the slugId is not in the tree. +export function findAncestorTrail( + nodes: SharedPageTreeNode[], + slugId: string, +): SharedPageTreeNode[] | null { + const walk = ( + list: SharedPageTreeNode[], + trail: SharedPageTreeNode[], + ): SharedPageTreeNode[] | null => { + for (const node of list) { + if (node.slugId === slugId) return trail; + if (node.children?.length) { + const found = walk(node.children, [...trail, node]); + if (found) return found; + } + } + return null; + }; + return walk(nodes, []); +} diff --git a/apps/client/src/features/public-space/utils/public-space-access.ts b/apps/client/src/features/public-space/utils/public-space-access.ts new file mode 100644 index 000000000..d10bc1753 --- /dev/null +++ b/apps/client/src/features/public-space/utils/public-space-access.ts @@ -0,0 +1,8 @@ +import { isBetaPublicSpaces } from "@/lib/config.ts"; +import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; + +export function isPublicSpacesAllowed(workspace?: IWorkspace): boolean { + return ( + isBetaPublicSpaces() && workspace?.settings?.publicSpaces?.enabled === true + ); +} diff --git a/apps/client/src/features/search/components/creator-filter-menu.tsx b/apps/client/src/features/search/components/creator-filter-menu.tsx new file mode 100644 index 000000000..ce4a93be1 --- /dev/null +++ b/apps/client/src/features/search/components/creator-filter-menu.tsx @@ -0,0 +1,152 @@ +import { ReactNode, useMemo, useState } from "react"; +import { Divider, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconCheck, IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query"; +import { RadioMenuItem } from "@/components/ui/radio-menu-item"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { IUser } from "@/features/user/types/user.types.ts"; +import { useAtomValue } from "jotai"; +import { userAtom } from "@/features/user/atoms/current-user-atom.ts"; + +type CreatorFilterMenuProps = { + value: string | null; + onChange: (user: IUser | null) => void; + children: ReactNode; + width?: number; + position?: + | "bottom-start" + | "bottom-end" + | "bottom" + | "top-start" + | "top-end" + | "top"; + zIndex?: number; + opened?: boolean; + onOpenChange?: (opened: boolean) => void; +}; + +export function CreatorFilterMenu({ + value, + onChange, + children, + width = 280, + position = "bottom-end", + zIndex, + opened, + onOpenChange, +}: CreatorFilterMenuProps) { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + + const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ + query: debouncedQuery, + includeUsers: true, + includeGroups: false, + includePages: false, + preload: true, + }); + + const users: IUser[] = (suggestion?.users as IUser[]) ?? []; + const currentUser = useAtomValue(userAtom); + + // pin the signed-in user on top so they never have to search themselves + const displayUsers = useMemo(() => { + if (!currentUser) return users; + const others = users.filter((user) => user.id !== currentUser.id); + const q = debouncedQuery.trim().toLowerCase(); + const matchesQuery = + !q || + currentUser.name?.toLowerCase().includes(q) || + currentUser.email?.toLowerCase().includes(q); + return matchesQuery ? [currentUser as IUser, ...others] : users; + }, [users, currentUser, debouncedQuery]); + + return ( + + {children} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + onChange(null)} + > + +
+ + {t("Anyone")} + +
+ {!value && } +
+
+ + + + {displayUsers.length === 0 && ( + + {isLoading ? t("Loading...") : t("No users found")} + + )} + + {displayUsers.map((user) => ( + onChange(user)} + > + + +
+ + {user.name} + {user.id === currentUser?.id && ( + + {" "} + ({t("you")}) + + )} + + {user.email && ( + + {user.email} + + )} +
+ {value === user.id && } +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/client/src/features/search/components/label-filter-menu.tsx b/apps/client/src/features/search/components/label-filter-menu.tsx new file mode 100644 index 000000000..2bc9db7a6 --- /dev/null +++ b/apps/client/src/features/search/components/label-filter-menu.tsx @@ -0,0 +1,127 @@ +import { ReactNode, useMemo, useState } from "react"; +import { + Group, + Menu, + ScrollArea, + Text, + TextInput, + useComputedColorScheme, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconCheck, IconSearch } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useWorkspaceLabelsQuery } from "@/features/label/queries/label-query.ts"; +import { getLabelColor } from "@/features/label/utils/label-colors.ts"; +import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item"; + +type LabelFilterMenuProps = { + value: string[]; + onChange: (labelIds: string[]) => void; + children: ReactNode; + width?: number; + position?: + | "bottom-start" + | "bottom-end" + | "bottom" + | "top-start" + | "top-end" + | "top"; + zIndex?: number; + opened?: boolean; + onOpenChange?: (opened: boolean) => void; +}; + +export function LabelFilterMenu({ + value, + onChange, + children, + width = 280, + position = "bottom-end", + zIndex, + opened, + onOpenChange, +}: LabelFilterMenuProps) { + const { t } = useTranslation(); + const scheme = useComputedColorScheme("light"); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(searchQuery, 300); + + const { data, isLoading } = useWorkspaceLabelsQuery(debouncedQuery, true); + const labels = data?.items ?? []; + + const selectedSet = useMemo(() => new Set(value), [value]); + + const toggleLabel = (labelId: string) => { + if (selectedSet.has(labelId)) { + onChange(value.filter((id) => id !== labelId)); + } else { + onChange([...value, labelId]); + } + }; + + return ( + + {children} + + } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + size="sm" + variant="filled" + radius="sm" + styles={{ input: { marginBottom: 8 } }} + /> + + + {labels.length === 0 && ( + + {isLoading ? t("Loading...") : t("No labels found")} + + )} + + {labels.map((label) => { + const isChecked = selectedSet.has(label.id); + const color = getLabelColor(label.name, scheme); + return ( + toggleLabel(label.id)} + > + + + + {label.name} + + {isChecked && } + + + ); + })} + + + + ); +} diff --git a/apps/client/src/features/search/components/public-space-search-spotlight.tsx b/apps/client/src/features/search/components/public-space-search-spotlight.tsx new file mode 100644 index 000000000..0dca1e053 --- /dev/null +++ b/apps/client/src/features/search/components/public-space-search-spotlight.tsx @@ -0,0 +1,112 @@ +import { Group, Center, Text, Button } from "@mantine/core"; +import { Spotlight } from "@mantine/spotlight"; +import { IconLetterCase, IconSearch } from "@tabler/icons-react"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { useDebouncedValue } from "@mantine/hooks"; +import { usePublicSpaceSearchQuery } from "@/features/search/queries/search-query"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getPageIcon } from "@/lib"; +import { useTranslation } from "react-i18next"; +import { publicSpaceSearchSpotlightStore } from "@/features/search/constants.ts"; +import DOMPurify from "dompurify"; + +interface PublicSpaceSearchSpotlightProps { + spaceSlug: string; +} +export function PublicSpaceSearchSpotlight({ + spaceSlug, +}: PublicSpaceSearchSpotlightProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [titleOnly, setTitleOnly] = useState(false); + const [debouncedSearchQuery] = useDebouncedValue(query, 300); + + const { data: searchResults } = usePublicSpaceSearchQuery({ + query: debouncedSearchQuery, + spaceSlug, + ...(titleOnly && { titleOnly: true }), + }); + + const pages = ( + searchResults && searchResults.length > 0 ? searchResults : [] + ).map((page) => ( + + +
{getPageIcon(page?.icon)}
+ +
+ {page.title} + + {page?.highlight && ( + + )} +
+
+
+ )); + + return ( + <> + + } + /> + + + + + {query.length === 0 && pages.length === 0 && ( + {t("Start typing to search...")} + )} + + {query.length > 0 && pages.length === 0 && ( + {t("No results found...")} + )} + + {pages.length > 0 && pages} + + + + ); +} diff --git a/apps/client/src/features/search/components/search-result-item.tsx b/apps/client/src/features/search/components/search-result-item.tsx index 86e139036..edb2ef8b7 100644 --- a/apps/client/src/features/search/components/search-result-item.tsx +++ b/apps/client/src/features/search/components/search-result-item.tsx @@ -19,6 +19,7 @@ import { } from "@/features/search/types/search.types"; import DOMPurify from "dompurify"; import { useTranslation } from "react-i18next"; +import { timeAgo } from "@/lib/time.ts"; interface SearchResultItemProps { result: IPageSearch | IAttachmentSearch; @@ -26,6 +27,14 @@ interface SearchResultItemProps { showSpace?: boolean; } +// Spotlight hardcodes tabIndex={-1} after spreading props; a ref wins and +// React never writes -1 back because the prop value never changes +const makeActionTabbable = (el: HTMLElement | null) => { + if (el) { + el.tabIndex = 0; + } +}; + export function SearchResultItem({ result, isAttachmentResult, @@ -46,6 +55,7 @@ export function SearchResultItem({ return ( -
- {attachmentResult.fileName} +
+ + {attachmentResult.fileName} + + {timeAgo(attachmentResult.updatedAt)} + + {attachmentResult.space.name} • {attachmentResult.page.title} @@ -96,6 +111,7 @@ export function SearchResultItem({ return (
{getPageIcon(pageResult?.icon)}
-
- {pageResult.title} +
+ + {pageResult.title || t("Untitled")} + + {timeAgo(pageResult.updatedAt)} + + {showSpace && pageResult.space && ( diff --git a/apps/client/src/features/search/components/search-spotlight-filters.module.css b/apps/client/src/features/search/components/search-spotlight-filters.module.css index e8073aab6..3709f2960 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.module.css +++ b/apps/client/src/features/search/components/search-spotlight-filters.module.css @@ -17,3 +17,10 @@ color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-6)); } } + +.filterButtonActive { + color: var(--mantine-color-blue-light-color); + &:hover { + color: var(--mantine-color-blue-light-color); + } +} diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index 0b2bcc48c..96a7178ad 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; +import cx from "clsx"; import { Button, Menu, @@ -11,18 +12,24 @@ import { import { IconChevronDown, IconBuilding, + IconPlus, IconFileDescription, IconCheck, + IconUser, + IconTag, + IconLetterCase, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useGetSpacesQuery } from "@/features/space/queries/space-query"; import { SpaceFilterMenu } from "@/features/space/components/space-filter-menu"; +import { CreatorFilterMenu } from "@/features/search/components/creator-filter-menu"; import { RadioMenuItem } from "@/components/ui/radio-menu-item"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; import classes from "./search-spotlight-filters.module.css"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { LabelFilterMenu } from "./label-filter-menu"; interface SearchSpotlightFiltersProps { onFiltersChange?: (filters: any) => void; @@ -40,9 +47,17 @@ export function SearchSpotlightFilters({ const { t } = useTranslation(); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const [selectedSpaceId, setSelectedSpaceId] = useState( - spaceId || null, + spaceId || null ); const [contentType, setContentType] = useState("page"); + const [selectedCreatorId, setSelectedCreatorId] = useState(null); + const [selectedCreatorName, setSelectedCreatorName] = useState( + null + ); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); + const [titleOnly, setTitleOnly] = useState(false); + const [openedFilter, setOpenedFilter] = useState(null); + const [visibleFilters, setVisibleFilters] = useState([]); const [workspace] = useAtom(workspaceAtom); const { data: spacesData } = useGetSpacesQuery({ limit: 100 }); @@ -50,15 +65,6 @@ export function SearchSpotlightFilters({ ? spacesData?.items.find((space) => space.id === selectedSpaceId) : null; - useEffect(() => { - if (onFiltersChange) { - onFiltersChange({ - spaceId: selectedSpaceId, - contentType, - }); - } - }, []); - const contentTypeOptions = [ { value: "page", label: t("Pages") }, { @@ -68,38 +74,70 @@ export function SearchSpotlightFilters({ }, ]; + useEffect(() => { + onFiltersChange?.({ + spaceId: selectedSpaceId, + contentType, + creatorId: selectedCreatorId, + labelIds: selectedLabelIds, + titleOnly, + }); + }, [ + selectedSpaceId, + contentType, + selectedCreatorId, + selectedLabelIds, + titleOnly, + onFiltersChange, + ]); + const handleSpaceSelect = (spaceId: string | null) => { setSelectedSpaceId(spaceId); + }; - if (onFiltersChange) { - onFiltersChange({ - spaceId: spaceId, - contentType, - }); + const handleCreatorSelect = (user: { id: string; name: string } | null) => { + setSelectedCreatorId(user?.id ?? null); + setSelectedCreatorName(user?.name ?? null); + }; + + const handleLabelsSelect = (labelIds: string[]) => { + setSelectedLabelIds(labelIds); + }; + + const handleChangeContentType = (value: string) => { + setContentType(value); + + if (value === "attachment") { + setSelectedLabelIds([]); } }; - const handleFilterChange = (filterType: string, value: any) => { - let newSelectedSpaceId = selectedSpaceId; - let newContentType = contentType; + const onDemandFilters = [ + { key: "creator", label: t("Created by"), icon: IconUser, available: true }, + { + key: "labels", + label: t("Labels"), + icon: IconTag, + available: contentType !== "attachment", + }, + ]; - switch (filterType) { - case "spaceId": - newSelectedSpaceId = value; - setSelectedSpaceId(value); - break; - case "contentType": - newContentType = value; - setContentType(value); - break; - } + const isFilterVisible = (key: string) => { + if (openedFilter === key) return true; + if (key === "creator") return !!selectedCreatorId; + if (key === "labels") + return contentType !== "attachment" && selectedLabelIds.length > 0; + return false; + }; - if (onFiltersChange) { - onFiltersChange({ - spaceId: newSelectedSpaceId, - contentType: newContentType, - }); - } + const orderedVisibleFilters = visibleFilters.filter(isFilterVisible); + const addableFilters = onDemandFilters.filter( + (filter) => filter.available && !isFilterVisible(filter.key), + ); + + const revealFilter = (key: string) => { + setVisibleFilters((prev) => [...prev.filter((k) => k !== key), key]); + setOpenedFilter(key); }; return ( @@ -122,8 +160,17 @@ export function SearchSpotlightFilters({ color="blue" labelPosition="left" styles={{ - root: { display: "flex", alignItems: "center" }, - label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 }, + root: { + display: "flex", + alignItems: "center", + flexShrink: 0, + }, + label: { + whiteSpace: "nowrap", + paddingRight: "8px", + fontSize: "13px", + fontWeight: 500, + }, }} />
@@ -181,7 +228,7 @@ export function SearchSpotlightFilters({ onClick={() => !option.disabled && contentType !== option.value && - handleFilterChange("contentType", option.value) + handleChangeContentType(option.value) } disabled={ option.disabled || (isAiMode && option.value === "attachment") @@ -195,13 +242,11 @@ export function SearchSpotlightFilters({ {t("Enterprise")} )} - {!option.disabled && - isAiMode && - option.value === "attachment" && ( - - {t("AI Answers not available for attachments")} - - )} + {!option.disabled && isAiMode && option.value === "attachment" && ( + + {t("AI Answers not available for attachments")} + + )}
{contentType === option.value && } @@ -209,6 +254,126 @@ export function SearchSpotlightFilters({ ))} + + {!isAiMode && ( + + )} + + {!isAiMode && + orderedVisibleFilters.map((filterKey) => { + if (filterKey === "creator") { + return ( + + setOpenedFilter(opened ? "creator" : null) + } + > + + + ); + } + + if (filterKey === "labels") { + return ( + + setOpenedFilter(opened ? "labels" : null) + } + > + + + ); + } + + return null; + })} + + {!isAiMode && addableFilters.length > 0 && ( + + + + + + {addableFilters.map((filter) => ( + } + onClick={() => revealFilter(filter.key)} + > + {filter.label} + + ))} + + + )}
); } diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 3ada7ab1f..4e7977650 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -1,7 +1,7 @@ import { Spotlight } from "@mantine/spotlight"; import { IconSearch, IconSparkles } from "@tabler/icons-react"; -import { Group, Button, VisuallyHidden } from "@mantine/core"; -import React, { useState, useMemo, useEffect } from "react"; +import { Group, Button, VisuallyHidden, Text } from "@mantine/core"; +import React, { useState, useMemo, useEffect, useCallback } from "react"; import { useDebouncedValue } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { notifications } from "@mantine/notifications"; @@ -31,6 +31,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { const [filters, setFilters] = useState<{ spaceId?: string | null; contentType?: string; + creatorId?: string | null; + labelIds?: string[]; + titleOnly?: boolean; }>({ contentType: "page", }); @@ -48,10 +51,25 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { params.spaceId = filters.spaceId; } + if (filters.creatorId) { + params.creatorId = filters.creatorId; + } + + if (filters.labelIds?.length) { + params.labelIds = filters.labelIds; + } + + if (filters.titleOnly) { + params.titleOnly = true; + } + return params; }, [debouncedSearchQuery, filters]); - const { data: searchResults, isLoading } = useUnifiedSearch( + const { + data: searchResults, + isFetching, + } = useUnifiedSearch( searchParams, !isAiMode // Disable regular search when in AI mode ); @@ -88,6 +106,11 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }, [aiSearchError, t]); + const isFilterBrowse = + (filters.labelIds?.length ?? 0) > 0 || !!filters.creatorId; + // while the debounce is pending the empty list is not a settled "no results" + const isQuerySettled = query === debouncedSearchQuery; + // Determine result type for rendering const isAttachmentSearch = filters.contentType === "attachment" && hasAttachmentIndexing; @@ -110,9 +133,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { } }; - const handleFiltersChange = (newFilters: any) => { + const handleFiltersChange = useCallback((newFilters: any) => { setFilters(newFilters); - }; + }, [setFilters]); const handleAskClick = () => { setIsAiMode(!isAiMode); @@ -182,7 +205,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ? query.length > 0 && !isAiLoading && !aiSearchResult ? t("No answer available") : "" - : query.length > 0 && !isLoading + : (query.length > 0 || isFilterBrowse) && !isFetching ? resultItems.length === 0 ? t("No results found") : t("{{count}} results found", { count: resultItems.length }) @@ -209,15 +232,28 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { ) : ( <> - {query.length === 0 && resultItems.length === 0 && ( + {query.length === 0 && !isFilterBrowse && resultItems.length === 0 && ( {t("Start typing to search...")} )} - {query.length > 0 && !isLoading && resultItems.length === 0 && ( - {t("No results found...")} - )} + {(query.length > 0 || isFilterBrowse) && + !isFetching && + isQuerySettled && + resultItems.length === 0 && ( + {t("No results found...")} + )} {resultItems.length > 0 && <>{resultItems}} + + {(query.length > 0 || isFilterBrowse) && + isFetching && + resultItems.length === 0 && ( + + + {t("Searching...")} + + + )} )} diff --git a/apps/client/src/features/search/constants.ts b/apps/client/src/features/search/constants.ts index a4c6c2f70..365387625 100644 --- a/apps/client/src/features/search/constants.ts +++ b/apps/client/src/features/search/constants.ts @@ -5,3 +5,6 @@ export const [searchSpotlightStore, searchSpotlight] = createSpotlight(); export const [shareSearchSpotlightStore, shareSearchSpotlight] = createSpotlight(); +export const [publicSpaceSearchSpotlightStore, publicSpaceSearchSpotlight] = + createSpotlight(); + diff --git a/apps/client/src/features/search/hooks/use-unified-search.ts b/apps/client/src/features/search/hooks/use-unified-search.ts index 5d294f4bd..9270477b9 100644 --- a/apps/client/src/features/search/hooks/use-unified-search.ts +++ b/apps/client/src/features/search/hooks/use-unified-search.ts @@ -39,6 +39,20 @@ export function useUnifiedSearch( return await searchPage(backendParams); } }, - enabled: !!params.query && enabled, + enabled: + (!!params.query || + (params.labelIds?.length ?? 0) > 0 || + !!params.creatorId) && + enabled, + // keep previous results only within the same search type; page results + // rendered as attachments (or vice versa) crash on missing fields + placeholderData: (previousData, previousQuery) => { + if (!params.query && !params.labelIds?.length && !params.creatorId) + return undefined; + if (previousQuery && previousQuery.queryKey[1] !== searchType) { + return undefined; + } + return previousData; + }, }); } diff --git a/apps/client/src/features/search/queries/search-query.ts b/apps/client/src/features/search/queries/search-query.ts index f536b441b..3cbc9a371 100644 --- a/apps/client/src/features/search/queries/search-query.ts +++ b/apps/client/src/features/search/queries/search-query.ts @@ -2,6 +2,7 @@ import { keepPreviousData, useQuery, UseQueryResult } from "@tanstack/react-quer import { searchAttachments, searchPage, + searchPublicSpace, searchShare, searchSuggestions, } from '@/features/search/services/search-service'; @@ -55,3 +56,13 @@ export function useAttachmentSearchQuery( enabled: !!params.query, }); } + +export function usePublicSpaceSearchQuery( + params: IPageSearchParams & { spaceSlug: string }, +): UseQueryResult { + return useQuery({ + queryKey: ["public-space-search", params], + queryFn: () => searchPublicSpace(params), + enabled: !!params.query && !!params.spaceSlug, + }); +} diff --git a/apps/client/src/features/search/services/search-service.ts b/apps/client/src/features/search/services/search-service.ts index 5e52ddf77..b94cccab9 100644 --- a/apps/client/src/features/search/services/search-service.ts +++ b/apps/client/src/features/search/services/search-service.ts @@ -28,6 +28,16 @@ export async function searchShare( return req.data.items; } +export async function searchPublicSpace( + params: IPageSearchParams & { spaceSlug: string }, +): Promise { + const req = await api.post<{ items: IPageSearch[] }>( + "/search/public-space-search", + params, + ); + return req.data.items; +} + export async function searchAttachments( params: IPageSearchParams, ): Promise { diff --git a/apps/client/src/features/search/types/search.types.ts b/apps/client/src/features/search/types/search.types.ts index 155e9932e..e90f06f3f 100644 --- a/apps/client/src/features/search/types/search.types.ts +++ b/apps/client/src/features/search/types/search.types.ts @@ -38,6 +38,9 @@ export interface IPageSearchParams { query: string; spaceId?: string; shareId?: string; + creatorId?: string; + labelIds?: string[]; + titleOnly?: boolean; } export interface IAttachmentSearch { diff --git a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts b/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts deleted file mode 100644 index 47882e5e9..000000000 --- a/apps/client/src/features/share/atoms/open-shared-tree-nodes-atom.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { atom } from "jotai"; - -export const openSharedTreeNodesAtom = atom>({}); diff --git a/apps/client/src/features/share/atoms/shared-page-atom.ts b/apps/client/src/features/share/atoms/shared-page-atom.ts index bf8929942..efb0940db 100644 --- a/apps/client/src/features/share/atoms/shared-page-atom.ts +++ b/apps/client/src/features/share/atoms/shared-page-atom.ts @@ -1,11 +1,10 @@ import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; import { ISharedPageTree } from "@/features/share/types/share.types"; import { SharedPageTreeNode } from "@/features/share/utils"; -export const sharedPageTreeAtom = atom(null); -export const sharedTreeDataAtom = atom(null); -export const sharedPageFullWidthAtom = atomWithStorage( - "sharedPageFullWidth", - false, -); \ No newline at end of file +export const sharedPageTreeAtom = atom( + null as ISharedPageTree | null, +); +export const sharedTreeDataAtom = atom( + null as SharedPageTreeNode[] | null, +); diff --git a/apps/client/src/features/share/atoms/sidebar-atom.ts b/apps/client/src/features/share/atoms/sidebar-atom.ts deleted file mode 100644 index 0bc9d6817..000000000 --- a/apps/client/src/features/share/atoms/sidebar-atom.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atomWithWebStorage } from "@/lib/jotai-helper.ts"; -import { atom } from 'jotai'; - -export const tableOfContentAsideAtom = atomWithWebStorage( - "showTOC", - true, -); - -export const mobileTableOfContentAsideAtom = atom(false); \ No newline at end of file diff --git a/apps/client/src/features/share/components/share-branding.tsx b/apps/client/src/features/share/components/share-branding.tsx deleted file mode 100644 index 4b3dfb3eb..000000000 --- a/apps/client/src/features/share/components/share-branding.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Affix, Button } from "@mantine/core"; - -export default function ShareBranding() { - return ( - - - - ); -} diff --git a/apps/client/src/features/share/components/share-layout.tsx b/apps/client/src/features/share/components/share-layout.tsx index e3b2eb177..21bfca77e 100644 --- a/apps/client/src/features/share/components/share-layout.tsx +++ b/apps/client/src/features/share/components/share-layout.tsx @@ -1,10 +1,67 @@ -import { Outlet } from "react-router-dom"; -import ShareShell from "@/features/share/components/share-shell.tsx"; +import "@fontsource-variable/inter"; +import "@/styles/public-typography.css"; +import { useEffect, useMemo } from "react"; +import { Outlet, useParams } from "react-router-dom"; +import { useSetAtom } from "jotai"; +import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts"; +import { buildSharedPageTree } from "@/features/share/utils.ts"; +import { + sharedPageTreeAtom, + sharedTreeDataAtom, +} from "@/features/share/atoms/shared-page-atom.ts"; +import DocsShell from "@/features/public-space/components/docs/docs-shell.tsx"; +import { DocsSurface } from "@/features/public-space/components/docs/docs-surface-context.tsx"; +import { useDocsAccent } from "@/features/public-space/theme/docs-theme.ts"; +import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; +import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx"; +import { shareSearchSpotlight } from "@/features/search/constants"; export default function ShareLayout() { + const { shareId } = useParams(); + const { data } = useGetSharedPageTreeQuery(shareId); + + // Shares have no appearance settings; apply the default docs accent. + useDocsAccent(undefined); + + const setSharedPageTree = useSetAtom(sharedPageTreeAtom); + const setSharedTreeData = useSetAtom(sharedTreeDataAtom); + + const treeData = useMemo(() => { + if (!data?.pageTree) return null; + return buildSharedPageTree(data.pageTree); + }, [data?.pageTree]); + + useEffect(() => { + setSharedPageTree(data || null); + setSharedTreeData(treeData); + }, [data, treeData, setSharedPageTree, setSharedTreeData]); + + const surface = useMemo( + () => ({ + treeData, + hasSidebar: (data?.pageTree?.length ?? 0) > 1, + getNodeUrl: (node) => + buildSharedPageUrl({ + shareId, + pageSlugId: node.slugId, + pageTitle: node.name, + }), + showBranding: Boolean(data), + showEditPage: true, + brandingRef: "public-share", + }), + [data, treeData, shareId], + ); + return ( - + : undefined + } + > - + ); } diff --git a/apps/client/src/features/share/components/share-shell.tsx b/apps/client/src/features/share/components/share-shell.tsx deleted file mode 100644 index 54ddbe347..000000000 --- a/apps/client/src/features/share/components/share-shell.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActionIcon, - AppShell, - Group, - ScrollArea, - Tooltip, -} from "@mantine/core"; -import { useGetSharedPageTreeQuery } from "@/features/share/queries/share-query.ts"; -import { useParams } from "react-router-dom"; -import SharedTree from "@/features/share/components/shared-tree.tsx"; -import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx"; -import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; -import { ThemeToggle } from "@/components/theme-toggle.tsx"; -import { useAtomValue, useSetAtom } from "jotai"; -import { useAtom } from "jotai"; -import { - sharedPageFullWidthAtom, - sharedPageTreeAtom, - sharedTreeDataAtom, -} from "@/features/share/atoms/shared-page-atom"; -import { buildSharedPageTree } from "@/features/share/utils"; -import { - desktopSidebarAtom, - mobileSidebarAtom, - sidebarWidthAtom, -} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; -import { useTranslation } from "react-i18next"; -import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; -import { - mobileTableOfContentAsideAtom, - tableOfContentAsideAtom, -} from "@/features/share/atoms/sidebar-atom.ts"; -import { IconArrowsHorizontal, IconList } from "@tabler/icons-react"; -import { useToggleToc } from "@/features/share/hooks/use-toggle-toc.ts"; -import classes from "./share.module.css"; -import { - SearchControl, - SearchMobileControl, -} from "@/features/search/components/search-control.tsx"; -import { ShareSearchSpotlight } from "@/features/search/components/share-search-spotlight.tsx"; -import { shareSearchSpotlight } from "@/features/search/constants"; -import ShareBranding from '@/features/share/components/share-branding.tsx'; -import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; - -const MemoizedSharedTree = React.memo(SharedTree); - -export default function ShareShell({ - children, -}: { - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const [mobileOpened] = useAtom(mobileSidebarAtom); - const [desktopOpened] = useAtom(desktopSidebarAtom); - const toggleMobile = useToggleSidebar(mobileSidebarAtom); - const toggleDesktop = useToggleSidebar(desktopSidebarAtom); - - const [tocOpened] = useAtom(tableOfContentAsideAtom); - const [mobileTocOpened] = useAtom(mobileTableOfContentAsideAtom); - const toggleTocMobile = useToggleToc(mobileTableOfContentAsideAtom); - const toggleToc = useToggleToc(tableOfContentAsideAtom); - const [fullWidth, setFullWidth] = useAtom(sharedPageFullWidthAtom); - const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom); - const [isResizing, setIsResizing] = useState(false); - const sidebarRef = useRef(null); - - const startResizing = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - }, []); - - const stopResizing = useCallback(() => { - setIsResizing(false); - }, []); - - const resize = useCallback( - (e: MouseEvent) => { - if (!isResizing || !sidebarRef.current) return; - const newWidth = - e.clientX - sidebarRef.current.getBoundingClientRect().left; - if (newWidth < 220) { - setSidebarWidth(220); - return; - } - if (newWidth > 600) { - setSidebarWidth(600); - return; - } - setSidebarWidth(newWidth); - }, - [isResizing, setSidebarWidth], - ); - - useEffect(() => { - window.addEventListener("mousemove", resize); - window.addEventListener("mouseup", stopResizing); - return () => { - window.removeEventListener("mousemove", resize); - window.removeEventListener("mouseup", stopResizing); - }; - }, [resize, stopResizing]); - - const { shareId } = useParams(); - const { data } = useGetSharedPageTreeQuery(shareId); - const readOnlyEditor = useAtomValue(readOnlyEditorAtom); - - // @ts-ignore - const setSharedPageTree = useSetAtom(sharedPageTreeAtom); - // @ts-ignore - const setSharedTreeData = useSetAtom(sharedTreeDataAtom); - - // Build and set the tree data when it changes - const treeData = useMemo(() => { - if (!data?.pageTree) return null; - return buildSharedPageTree(data.pageTree); - }, [data?.pageTree]); - - useEffect(() => { - setSharedPageTree(data || null); - setSharedTreeData(treeData); - }, [data, treeData, setSharedPageTree, setSharedTreeData]); - - return ( - <> - - 1 && { - navbar: { - width: sidebarWidth, - breakpoint: "sm", - collapsed: { - mobile: !mobileOpened, - desktop: !desktopOpened, - }, - }, - })} - aside={{ - width: 300, - breakpoint: "sm", - collapsed: { - mobile: !mobileTocOpened, - desktop: !tocOpened, - }, - }} - padding="md" - > - - - - {data?.pageTree?.length > 1 && ( - <> - - - - - - - - - )} - - - {shareId && ( - - - - )} - - - <> - {shareId && ( - - - - )} - - - - - - - - - - - - - - - setFullWidth((v) => !v)} - visibleFrom="sm" - size="sm" - > - - - - - - - - - - - {data?.pageTree?.length > 1 && ( - -
- - - )} - - - {children} - - {data && shareId && !(data.features?.length > 0) && } - - - - -
- {readOnlyEditor && ( - - )} -
-
-
- - - - - ); -} diff --git a/apps/client/src/features/share/components/share.module.css b/apps/client/src/features/share/components/share.module.css index ebf1e74cb..99e84e413 100644 --- a/apps/client/src/features/share/components/share.module.css +++ b/apps/client/src/features/share/components/share.module.css @@ -6,39 +6,3 @@ border-bottom: 0.05em solid var(--mantine-color-dark-2); } } - -.treeNode { - text-decoration: none; - user-select: none; - padding-bottom: 0; -} - -.navbar, -.aside { - @media (max-width: $mantine-breakpoint-sm) { - width: 350px; - } -} - -.resizeHandle { - width: 3px; - cursor: col-resize; - position: absolute; - right: 0; - top: 0; - bottom: 0; - z-index: 1; - - &:hover, - &:active { - width: 5px; - background: light-dark( - var(--mantine-color-gray-4), - var(--mantine-color-dark-5) - ); - } - - @media (max-width: $mantine-breakpoint-sm) { - display: none; - } -} diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx deleted file mode 100644 index 370c59e70..000000000 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { ISharedPageTree } from "@/features/share/types/share.types.ts"; -import { - buildSharedPageTree, - SharedPageTreeNode, -} from "@/features/share/utils.ts"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Link, useParams } from "react-router-dom"; -import { useAtom } from "jotai"; -import { useTranslation } from "react-i18next"; -import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; -import clsx from "clsx"; -import { - IconChevronDown, - IconChevronRight, - IconFileDescription, - IconPointFilled, -} from "@tabler/icons-react"; -import { ActionIcon, Box } from "@mantine/core"; -import { extractPageSlugId } from "@/lib"; -import classes from "@/features/page/tree/styles/tree.module.css"; -import styles from "./share.module.css"; -import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; -import EmojiPicker from "@/components/ui/emoji-picker.tsx"; -import { - DocTree, - type DocTreeApi, - type RenderRowProps, -} from "@/features/page/tree/components/doc-tree"; -import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom"; - -interface SharedTreeProps { - sharedPageTree: ISharedPageTree; -} - -export default function SharedTree({ sharedPageTree }: SharedTreeProps) { - const { t } = useTranslation(); - const treeRef = useRef(null); - const { pageSlug } = useParams(); - const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom); - - const currentNodeId = extractPageSlugId(pageSlug); - - const treeData: SharedPageTreeNode[] = useMemo(() => { - if (!sharedPageTree?.pageTree) return [] as SharedPageTreeNode[]; - return buildSharedPageTree(sharedPageTree.pageTree); - }, [sharedPageTree?.pageTree]); - - const openIds = useMemo( - () => - new Set( - Object.keys(openTreeNodes).filter((k) => openTreeNodes[k]), - ), - [openTreeNodes], - ); - - useEffect(() => { - // Auto-open the first level of the shared tree on initial load. - const root = treeData?.[0]; - if (!root) return; - setOpenTreeNodes((prev) => { - if (prev[root.slugId]) return prev; - const next = { ...prev, [root.slugId]: true }; - for (const child of root.children ?? []) { - next[child.slugId] = true; - } - return next; - }); - }, [treeData, setOpenTreeNodes]); - - useEffect(() => { - if (currentNodeId) { - treeRef.current?.select(currentNodeId, { scrollIntoView: true }); - } - }, [currentNodeId, treeData]); - - // Stable callbacks so memo(DocTreeRow) actually saves work — see I2 in the - // post-implementation code review. - const handleToggle = useCallback( - (id: string, isOpen: boolean) => - setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen })), - [setOpenTreeNodes], - ); - const getDragLabel = useCallback( - (n: SharedPageTreeNode) => n.name || "untitled", - [], - ); - - if (!sharedPageTree || !sharedPageTree?.pageTree) { - return null; - } - - return ( -
- - readOnly - ref={treeRef} - data={treeData} - openIds={openIds} - selectedId={currentNodeId} - renderRow={SharedTreeRow} - onMove={noopMove} - onToggle={handleToggle} - getDragLabel={getDragLabel} - aria-label={t("Pages")} - /> -
- ); -} - -// Module-scope noop so it's a stable reference across renders. -const noopMove = () => {}; - -function SharedTreeRow({ - node, - isOpen, - hasChildren, - isSelected, - rowRef, - tabIndex, - treeItemProps, - toggleOpen, -}: RenderRowProps) { - const { shareId } = useParams(); - const { t } = useTranslation(); - const [, setMobileSidebarState] = useAtom(mobileSidebarAtom); - - const pageUrl = buildSharedPageUrl({ - shareId: shareId, - pageSlugId: node.slugId, - pageTitle: node.name, - }); - - return ( - } - tabIndex={tabIndex} - {...treeItemProps} - data-selected={isSelected || undefined} - className={clsx(classes.node, styles.treeNode)} - component={Link} - to={pageUrl} - onClick={() => { - setMobileSidebarState(false); - }} - > - -
- {}} - icon={ - node.icon ? ( - node.icon - ) : ( - - ) - } - readOnly={true} - removeEmojiAction={() => {}} - actionIconProps={{ tabIndex: -1 }} - /> -
- {node.name || t("untitled")} -
- ); -} - -interface SharedPageArrowProps { - isOpen: boolean; - hasChildren: boolean; - onToggle: () => void; -} - -function SharedPageArrow({ - isOpen, - hasChildren, - onToggle, -}: SharedPageArrowProps) { - if (!hasChildren) { - return ( - - - - ); - } - - return ( - { - e.preventDefault(); - e.stopPropagation(); - onToggle(); - }} - > - {isOpen ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts index 6112d3fdb..76b17912e 100644 --- a/apps/client/src/features/share/hooks/use-shared-page-subpages.ts +++ b/apps/client/src/features/share/hooks/use-shared-page-subpages.ts @@ -1,29 +1,13 @@ import { useMemo } from "react"; import { useAtomValue } from "jotai"; import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom"; -import { SharedPageTreeNode } from "@/features/share/utils"; +import { findSubpagesInTree } from "@/features/share/utils"; export function useSharedPageSubpages(pageId: string | undefined) { const treeData = useAtomValue(sharedTreeDataAtom); - return useMemo(() => { - if (!treeData || !pageId) return []; - - function findSubpages(nodes: SharedPageTreeNode[]): SharedPageTreeNode[] { - for (const node of nodes) { - if (node.value === pageId || node.slugId === pageId) { - return node.children || []; - } - if (node.children && node.children.length > 0) { - const subpages = findSubpages(node.children); - if (subpages.length > 0) { - return subpages; - } - } - } - return []; - } - - return findSubpages(treeData); - }, [treeData, pageId]); + return useMemo( + () => findSubpagesInTree(treeData, pageId), + [treeData, pageId], + ); } diff --git a/apps/client/src/features/share/hooks/use-toggle-toc.ts b/apps/client/src/features/share/hooks/use-toggle-toc.ts deleted file mode 100644 index ec43086a7..000000000 --- a/apps/client/src/features/share/hooks/use-toggle-toc.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useAtom } from "jotai"; - -export function useToggleToc(tocAtom: any) { - const [tocState, setTocState] = useAtom(tocAtom); - return () => { - setTocState(!tocState); - } -} diff --git a/apps/client/src/features/share/utils.ts b/apps/client/src/features/share/utils.ts index d52016932..334cc786f 100644 --- a/apps/client/src/features/share/utils.ts +++ b/apps/client/src/features/share/utils.ts @@ -68,6 +68,27 @@ export function buildSharedPageTree( } +// Returns the children of the node matching the page id or slugId. +export function findSubpagesInTree( + tree: SharedPageTreeNode[] | null | undefined, + pageId: string | undefined, +): SharedPageTreeNode[] { + if (!tree || !pageId) return []; + + for (const node of tree) { + if (node.value === pageId || node.slugId === pageId) { + return node.children || []; + } + if (node.children && node.children.length > 0) { + const subpages = findSubpagesInTree(node.children, pageId); + if (subpages.length > 0) { + return subpages; + } + } + } + return []; +} + // Recursively checks if a page exists in the shared page tree. export function isPageInTree( tree: SharedPageTreeNode[], diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx index 3d24d100c..9383feda4 100644 --- a/apps/client/src/features/space/components/settings-modal.tsx +++ b/apps/client/src/features/space/components/settings-modal.tsx @@ -4,6 +4,8 @@ import AddSpaceMembersModal from "@/features/space/components/add-space-members- import React from "react"; import SpaceDetails from "@/features/space/components/space-details.tsx"; import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx"; +import PublishSpaceSettings from "@/features/public-space/components/publish-space-settings.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { @@ -11,6 +13,8 @@ import { SpaceCaslSubject, } from "@/features/space/permissions/permissions.type.ts"; import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; interface SpaceSettingsModalProps { spaceId: string; @@ -29,6 +33,13 @@ export default function SpaceSettingsModal({ const spaceRules = space?.membership?.permissions; const spaceAbility = useSpaceAbility(spaceRules); + const [workspace] = useAtom(workspaceAtom); + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + const canManageSettings = spaceAbility.can( + SpaceCaslAction.Manage, + SpaceCaslSubject.Settings, + ); + return ( <> {t("Members")} - {spaceAbility.can( - SpaceCaslAction.Manage, - SpaceCaslSubject.Settings, - ) && ( + {canManageSettings && allowPublicSpaces && ( + + {t("Publish")} + + )} + {canManageSettings && ( {t("Security")} @@ -114,6 +127,16 @@ export default function SpaceSettingsModal({
+ + + +
+ {canManageSettings && allowPublicSpaces && ( + + )} +
+
+
diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index f5c6ea0e7..dc13bc46b 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -35,7 +35,7 @@ import clsx from "clsx"; import { useDisclosure } from "@mantine/hooks"; import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; -import { getSpaceUrl } from "@/lib/config.ts"; +import { getSpaceUrl, isBetaPublicSpaces } from "@/lib/config.ts"; import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts"; import { @@ -54,7 +54,9 @@ import { import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts"; import { searchSpotlight } from "@/features/search/constants"; -import TemplatePickerModal from "@/ee/template/components/template-picker-modal"; +const TemplatePickerModal = React.lazy( + () => import("@/ee/template/components/template-picker-modal"), +); import { useHasFeature } from "@/ee/hooks/use-feature"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { Feature } from "@/ee/features"; @@ -104,6 +106,7 @@ export function SpaceSidebar() { spaceName={space?.name} spaceSlug={space?.slug} spaceIcon={space?.logo} + isPublished={isBetaPublicSpaces() && space?.isPublished} />
@@ -406,11 +409,13 @@ function SpaceMenu({ {hasTemplates && templatePickerOpened && ( null}> - + + + )} diff --git a/apps/client/src/features/space/components/sidebar/switch-space.tsx b/apps/client/src/features/space/components/sidebar/switch-space.tsx index ff07572fd..447a5bf94 100644 --- a/apps/client/src/features/space/components/sidebar/switch-space.tsx +++ b/apps/client/src/features/space/components/sidebar/switch-space.tsx @@ -2,24 +2,32 @@ import classes from "./switch-space.module.css"; import { useNavigate } from "react-router-dom"; import { SpaceSelect } from "./space-select"; import { getSpaceUrl } from "@/lib/config"; -import { Button, Popover, Text } from "@mantine/core"; -import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { Button, Popover, Text, Tooltip } from "@mantine/core"; +import { + IconChevronDown, + IconChevronUp, + IconWorld, +} from "@tabler/icons-react"; import { useDisclosure } from "@mantine/hooks"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import React from "react"; +import { useTranslation } from "react-i18next"; interface SwitchSpaceProps { spaceName: string; spaceSlug: string; spaceIcon?: string; + isPublished?: boolean; } export function SwitchSpace({ spaceName, spaceSlug, spaceIcon, + isPublished, }: SwitchSpaceProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const [opened, { close, toggle }] = useDisclosure(false); @@ -61,6 +69,16 @@ export function SwitchSpace({ {spaceName} + + {isPublished && ( + + + + )} diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts index 6937b233c..733a17d32 100644 --- a/apps/client/src/features/space/types/space.types.ts +++ b/apps/client/src/features/space/types/space.types.ts @@ -30,6 +30,7 @@ export interface ISpace { createdAt: Date; updatedAt: Date; memberCount?: number; + isPublished?: boolean; spaceId?: string; membership?: IMembership; settings?: ISpaceSettings; diff --git a/apps/client/src/features/transclusion/services/transclusion-api.ts b/apps/client/src/features/transclusion/services/transclusion-api.ts index 9aa695221..bf164d988 100644 --- a/apps/client/src/features/transclusion/services/transclusion-api.ts +++ b/apps/client/src/features/transclusion/services/transclusion-api.ts @@ -19,6 +19,14 @@ export async function lookupTransclusionForShare(params: { return r.data; } +export async function lookupTransclusionForPublicSpace(params: { + spaceSlug: string; + references: Array<{ sourcePageId: string; transclusionId: string }>; +}): Promise<{ items: TransclusionLookup[] }> { + const r = await api.post("/public-spaces/transclusion/lookup", params); + return r.data; +} + export async function listReferences(params: { sourcePageId: string; transclusionId: string; diff --git a/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx b/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx new file mode 100644 index 000000000..90078f633 --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/allow-public-spaces.tsx @@ -0,0 +1,129 @@ +import { Group, Text, Switch } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useAtom } from "jotai"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { notifications } from "@mantine/notifications"; + +export default function AllowPublicSpaces() { + const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); + + return ( + <> + +
+ {t("Allow public spaces")} + + {t("Space admins can publish their spaces to the web.")} + +
+ + +
+ + {workspace?.settings?.publicSpaces?.enabled === true && ( + +
+ {t("Show public directory")} + + {t("List published spaces at /docs for anyone to browse.")} + +
+ + +
+ )} + + ); +} + +function PublicSpacesDirectoryToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.publicSpaces?.directory === true, + ); + + const handleChange = async (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + try { + const updatedWorkspace = await updateWorkspace({ + publicSpacesDirectory: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + return ( + + ); +} + +function AllowPublicSpacesToggle() { + const { t } = useTranslation(); + const [workspace, setWorkspace] = useAtom(workspaceAtom); + const [checked, setChecked] = useState( + workspace?.settings?.publicSpaces?.enabled === true, + ); + + const applyChange = async (value: boolean) => { + try { + const updatedWorkspace = await updateWorkspace({ + allowPublicSpaces: value, + }); + setChecked(value); + setWorkspace(updatedWorkspace); + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + const value = event.currentTarget.checked; + modals.openConfirmModal({ + title: value ? t("Allow public spaces") : t("Disable public spaces"), + children: ( + + {value + ? t( + "Space admins will be able to make their spaces publicly readable by anyone on the internet. Are you sure?", + ) + : t( + "This will immediately unpublish every published space. Re-enabling later will not republish them. Are you sure?", + )} + + ), + centered: true, + labels: { + confirm: value ? t("Allow") : t("Disable"), + cancel: t("Cancel"), + }, + confirmProps: value ? undefined : { color: "red" }, + onConfirm: () => applyChange(value), + }); + }; + + return ( + + ); +} diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts index dd7124dfb..2d3c8679d 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -24,7 +24,12 @@ export interface IWorkspace { aiSearch?: boolean; generativeAi?: boolean; disablePublicSharing?: boolean; + allowPublicSpaces?: boolean; + publicSpacesDirectory?: boolean; mcpEnabled?: boolean; + aiChatReadOnly?: boolean; + aiChatWorkspaceKnowledgeOnly?: boolean; + enforceMcpOauth?: boolean; trashRetentionDays?: number; restrictApiToAdmins?: boolean; allowMemberTemplates?: boolean; @@ -39,6 +44,7 @@ export interface IWorkspaceSettings { api?: IWorkspaceApiSettings; templates?: IWorkspaceTemplateSettings; spaces?: IWorkspaceSpaceSettings; + publicSpaces?: IWorkspacePublicSpacesSettings; defaultPageEditMode?: string; } @@ -50,7 +56,10 @@ export interface IWorkspaceAiSettings { search?: boolean; generative?: boolean; mcp?: boolean; + enforceMcpOauth?: boolean; chat?: boolean; + chatReadOnly?: boolean; + chatWorkspaceKnowledgeOnly?: boolean; } export interface IWorkspaceSharingSettings { @@ -65,6 +74,11 @@ export interface IWorkspaceSpaceSettings { allowPersonal?: boolean; } +export interface IWorkspacePublicSpacesSettings { + enabled?: boolean; + directory?: boolean; +} + export interface ICreateInvite { role: string; emails: string[]; diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts index 61368646d..cde9317ac 100644 --- a/apps/client/src/lib/api-client.ts +++ b/apps/client/src/lib/api-client.ts @@ -32,6 +32,13 @@ api.interceptors.response.use( const url = new URL(error.request.responseURL)?.pathname; if (url === "/api/auth/collab-token") return; if (window.location.pathname.startsWith("/share/")) return; + // public docs probe authed endpoints; reject without the login redirect + if ( + window.location.pathname === "/docs" || + window.location.pathname.startsWith("/docs/") + ) { + break; + } // Handle unauthorized error redirectToLogin(); @@ -76,6 +83,8 @@ 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; diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts index 388447060..246017114 100644 --- a/apps/client/src/lib/config.ts +++ b/apps/client/src/lib/config.ts @@ -43,6 +43,10 @@ export function isCloud(): boolean { return castToBoolean(getConfigValue("CLOUD")); } +export function isBetaPublicSpaces(): boolean { + return castToBoolean(getConfigValue("BETA_PUBLIC_SPACES")); +} + export function getAiVectorDriver(): string { return getConfigValue("AI_VECTOR_DRIVER"); } diff --git a/apps/client/src/pages/public-space/public-space-directory-page.tsx b/apps/client/src/pages/public-space/public-space-directory-page.tsx new file mode 100644 index 000000000..1b50aea06 --- /dev/null +++ b/apps/client/src/pages/public-space/public-space-directory-page.tsx @@ -0,0 +1,235 @@ +import "@fontsource-variable/inter"; +import "@/styles/public-typography.css"; +import { useMemo, useState } from "react"; +import { Skeleton } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import clsx from "clsx"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { IconSearch } from "@tabler/icons-react"; +import { usePublicSpaceDirectoryQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { useAuthenticatedUser } from "@/features/public-space/hooks/use-authenticated-user.ts"; +import { buildPublicSpaceUrl } from "@/features/page/page.utils.ts"; +import { getAvatarUrl } from "@/lib/config.ts"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx"; +import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; +import styles from "@/features/public-space/components/docs/docs-hub.module.css"; + +const TILE_COLORS = [ + "#1f9d55", + "#2b4bd6", + "#7c3aed", + "#0e7490", + "#d9480f", + "#b42318", + "#a16207", + "#475569", + "#be185d", + "#0f766e", + "#4338ca", + "#65a30d", +]; + +function getInitials(name: string) { + return name + .split(/[\s&]+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0]) + .join("") + .toUpperCase(); +} + +export default function PublicSpaceDirectoryPage() { + const { t } = useTranslation(); + const { data, isLoading, isError, error } = usePublicSpaceDirectoryQuery(); + const { data: currentUser } = useAuthenticatedUser(); + const isMobile = useMediaQuery("(max-width: 48em)"); + const [query, setQuery] = useState(""); + + const spaces = useMemo(() => { + const all = (data?.spaces ?? []).map((space, index) => ({ + ...space, + color: TILE_COLORS[index % TILE_COLORS.length], + initials: getInitials(space.name), + })); + const needle = query.trim().toLowerCase(); + if (!needle) return all; + return all.filter( + (space) => + space.name.toLowerCase().includes(needle) || + space.description?.toLowerCase().includes(needle), + ); + }, [data?.spaces, query]); + + if (isError) { + if ([401, 403, 404].includes(error?.["response"]?.status)) { + return ; + } + return
{t("Error fetching page data.")}
; + } + + const total = data?.spaces.length ?? 0; + const title = t("Documentation"); + const searchLabel = isMobile ? t("Search...") : t("Search documentation..."); + + return ( +
+ + + + +
+
+ + + {title.charAt(0).toUpperCase()} + + {title} + + +
+ {currentUser?.user ? ( + + {t("Open app")} + + ) : ( + + {t("Sign in")} + + )} +
+
+
+ +
+
+

+ {t("Welcome to our documentation")} +

+

+ {isMobile + ? t("Guides, references and answers across all our spaces.") + : t( + "Guides, references and answers across all our published spaces.", + )} +

+ +
event.preventDefault()} + > + setQuery(event.target.value)} + placeholder={searchLabel} + aria-label={searchLabel} + /> + +
+
+
+ +
+
+
+

{t("Spaces")}

+ {!isLoading && ( + + {total === 1 + ? t("1 published space") + : t("{{count}} published spaces", { count: total })} + + )} +
+ + {isLoading && ( +
+ + + + +
+ )} + + {!isLoading && total === 0 && ( +

{t("No public spaces yet.")}

+ )} + + {!isLoading && total > 0 && spaces.length === 0 && ( +

+ {t("No spaces match your search.")} +

+ )} + + {spaces.length > 0 && ( +
+ {spaces.map((space) => { + const logoUrl = getAvatarUrl( + space.logo, + AvatarIconType.SPACE_ICON, + ); + return ( + + + + {logoUrl ? ( + + ) : ( + space.initials + )} + + {space.name} + + {space.description && ( + + {space.description} + + )} + + ); + })} +
+ )} +
+
+ + {data && ( + + )} +
+ ); +} diff --git a/apps/client/src/pages/public-space/public-space-page.tsx b/apps/client/src/pages/public-space/public-space-page.tsx new file mode 100644 index 000000000..d3fc09117 --- /dev/null +++ b/apps/client/src/pages/public-space/public-space-page.tsx @@ -0,0 +1,125 @@ +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Skeleton, Stack, Text } from "@mantine/core"; +import React from "react"; +import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; +import { extractPageSlugId } from "@/lib"; +import { Error404 } from "@/components/ui/error-404.tsx"; +import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts"; +import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import DocsBreadcrumbs from "@/features/public-space/components/docs/docs-breadcrumbs.tsx"; +import DocsPageNav from "@/features/public-space/components/docs/docs-page-nav.tsx"; +import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; +import { timeAgo } from "@/lib/time.ts"; +import styles from "@/features/public-space/components/docs/docs.module.css"; + +export default function PublicSpacePage() { + const { t } = useTranslation(); + const { spaceSlug, pageSlug } = useParams(); + + const { data, isLoading, isError, error } = usePublicSpacePageQuery({ + spaceSlug, + pageSlugId: pageSlug ? extractPageSlugId(pageSlug) : undefined, + }); + + if (isLoading) { + return ( + + + + + + + + + ); + } + + if (isError || !data) { + if ([401, 403, 404].includes(error?.["status"])) { + return ; + } + return
{t("Error fetching page data.")}
; + } + + if (data.page === null) { + return ( +
+ {t("This space has no public pages yet.")} +
+ ); + } + + const showAuthor = + data.byline?.author === true && Boolean(data.page.creator?.name); + const showUpdatedAt = + data.byline?.updatedAt === true && Boolean(data.page.updatedAt); + + return ( +
+ + {!data.searchIndexing && } + + + + + + } + trailingSpace={false} + /> + + +
+ ); +} + +type DocsBylineProps = { + creator?: { name: string; avatarUrl: string }; + updatedAt?: Date | string; +}; + +function DocsByline({ creator, updatedAt }: DocsBylineProps) { + const { t } = useTranslation(); + + if (!creator && !updatedAt) return null; + + return ( +
+ {creator && ( + + + {t("By {{name}}", { name: creator.name })} + + )} + + {creator && updatedAt && ( + + • + + )} + + {updatedAt && ( + + {t("Updated {{date}}", { date: timeAgo(new Date(updatedAt)) })} + + )} +
+ ); +} diff --git a/apps/client/src/pages/settings/shares/shares.tsx b/apps/client/src/pages/settings/shares/shares.tsx index d327681af..23a74827e 100644 --- a/apps/client/src/pages/settings/shares/shares.tsx +++ b/apps/client/src/pages/settings/shares/shares.tsx @@ -1,19 +1,23 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import { useTranslation } from "react-i18next"; +import { useAtom } from "jotai"; import ShareList from "@/features/share/components/share-list.tsx"; -import { Alert, Text } from "@mantine/core"; +import PublishedSpacesList from "@/features/public-space/components/published-spaces-list.tsx"; +import { isPublicSpacesAllowed } from "@/features/public-space/utils/public-space-access.ts"; +import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { Alert, Tabs } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import React from "react"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; export default function Shares() { const { t } = useTranslation(); + const [workspace] = useAtom(workspaceAtom); - return ( + const allowPublicSpaces = isPublicSpacesAllowed(workspace); + + const sharedPages = ( <> - - - }> {t( "Publicly shared pages from spaces you are a member of will appear here", @@ -23,4 +27,38 @@ export default function Shares() { ); + + return ( + <> + + + + {allowPublicSpaces ? ( + + + + {t("Shared pages")} + + + {t("Published spaces")} + + + + + {sharedPages} + + + + }> + {t("Spaces published to the web will appear here")} + + + + + + ) : ( + sharedPages + )} + + ); } diff --git a/apps/client/src/pages/settings/workspace/workspace-settings.tsx b/apps/client/src/pages/settings/workspace/workspace-settings.tsx index dc9e7c5fc..e43586ecc 100644 --- a/apps/client/src/pages/settings/workspace/workspace-settings.tsx +++ b/apps/client/src/pages/settings/workspace/workspace-settings.tsx @@ -2,12 +2,13 @@ import SettingsTitle from "@/components/settings/settings-title.tsx"; import WorkspaceNameForm from "@/features/workspace/components/settings/components/workspace-name-form"; import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx"; import { useTranslation } from "react-i18next"; -import { isCloud } from "@/lib/config.ts"; +import { isBetaPublicSpaces, isCloud } from "@/lib/config.ts"; import ManageHostname from "@/ee/components/manage-hostname.tsx"; import { Divider } from "@mantine/core"; import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx"; import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx"; import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx"; +import AllowPublicSpaces from "@/features/workspace/components/settings/components/allow-public-spaces.tsx"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; export default function WorkspaceSettings() { @@ -25,6 +26,13 @@ export default function WorkspaceSettings() { + {isBetaPublicSpaces() && ( + <> + + + + )} + {isCloud() && ( <> diff --git a/apps/client/src/pages/share/shared-page.tsx b/apps/client/src/pages/share/shared-page.tsx index b7028b2fb..dbce86233 100644 --- a/apps/client/src/pages/share/shared-page.tsx +++ b/apps/client/src/pages/share/shared-page.tsx @@ -1,19 +1,18 @@ import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useSharePageQuery } from "@/features/share/queries/share-query.ts"; -import { Container } from "@mantine/core"; +import { Skeleton, Stack } from "@mantine/core"; import React, { useEffect } from "react"; import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx"; import { extractPageSlugId } from "@/lib"; import { Error404 } from "@/components/ui/error-404.tsx"; -import ShareBranding from "@/features/share/components/share-branding.tsx"; import { useAtomValue } from "jotai"; -import { - sharedPageFullWidthAtom, - sharedTreeDataAtom, -} from "@/features/share/atoms/shared-page-atom.ts"; +import { sharedTreeDataAtom } from "@/features/share/atoms/shared-page-atom.ts"; import { isPageInTree } from "@/features/share/utils.ts"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; +import DocsBreadcrumbs from "@/features/public-space/components/docs/docs-breadcrumbs.tsx"; +import DocsPageNav from "@/features/public-space/components/docs/docs-page-nav.tsx"; +import DocsFooterBranding from "@/features/public-space/components/docs/docs-footer-branding.tsx"; export default function SharedPage() { const { t } = useTranslation(); @@ -26,7 +25,6 @@ export default function SharedPage() { }); const sharedTreeData = useAtomValue(sharedTreeDataAtom); - const fullWidth = useAtomValue(sharedPageFullWidthAtom); useEffect(() => { if (shareId && data) { @@ -44,7 +42,16 @@ export default function SharedPage() { }, [shareId, data, sharedTreeData]); if (isLoading) { - return <>; + return ( + + + + + + + + + ); } if (isError || !data) { @@ -65,17 +72,21 @@ export default function SharedPage() { )} - - - + - {data && !shareId && !(data.features?.length > 0) && } + + + + + {/* No tree query without a shareId, so the shell can't own branding here. */} + {!shareId && }
); } diff --git a/apps/client/src/pages/space/space-home.tsx b/apps/client/src/pages/space/space-home.tsx index df8e297d2..8b349edde 100644 --- a/apps/client/src/pages/space/space-home.tsx +++ b/apps/client/src/pages/space/space-home.tsx @@ -1,5 +1,6 @@ import {Container} from "@mantine/core"; import SpaceHomeTabs from "@/features/space/components/space-home-tabs.tsx"; +import SpacePublicNotice from "@/features/public-space/components/space-public-notice.tsx"; import {useParams} from "react-router-dom"; import {useGetSpaceBySlugQuery} from "@/features/space/queries/space-query.ts"; import { DocumentTitle } from "@/components/ui/document-title.tsx"; @@ -12,6 +13,7 @@ export default function SpaceHome() { <> + {space && } {space && } diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css index 25c1eafe7..5114b5865 100644 --- a/apps/client/src/styles/a11y-overrides.css +++ b/apps/client/src/styles/a11y-overrides.css @@ -33,3 +33,41 @@ color: var(--mantine-color-dimmed); -webkit-text-fill-color: var(--mantine-color-dimmed); } + +/* Spotlight's selected action ships as primary-filled blue with white text, + * but our custom action children (gray badge, dimmed snippet, gray icons) + * keep their light-surface colors on it and drop below WCAG AA 4.5:1 + * (WCAG 1.4.3). Use a gray selection one step above the gray-0/dark-6 + * hover instead - it matches the app's selection styling (we use no blue + * fills) and keeps every child at its already-passing resting contrast. + */ +.mantine-Spotlight-action[data-selected] { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + color: var(--mantine-color-text); + --action-description-color: var(--mantine-color-dimmed); + --action-description-opacity: 1; +} + +/* Result actions are tab stops (tabIndex restored via ref); give keyboard + * focus the same gray treatment as arrow-key selection so both navigation + * modes read identically (WCAG 2.4.7 focus visible). + */ +.mantine-Spotlight-action:focus-visible { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); +} + +/* The skip-link target main is tabindex="-1", so clicks in readonly content + * focus it and overlays (e.g. the lightbox) return focus to it on close, + * ringing the whole content region. The skip link's purpose is reading + * position, not a visible ring around everything, so suppress the outline. + */ +#main-content:focus, +#main-content:focus-visible { + outline: none; +} diff --git a/apps/client/src/styles/public-typography.css b/apps/client/src/styles/public-typography.css new file mode 100644 index 000000000..505e315b3 --- /dev/null +++ b/apps/client/src/styles/public-typography.css @@ -0,0 +1,39 @@ +/* Reading typography for the public surfaces (public spaces and share links). + * Scoped to the .public-typography root so the authenticated app keeps the + * system font stack. Loaded only from the lazy public layouts. */ + +.public-typography { + font-family: "Inter Variable", var(--mantine-font-family); + letter-spacing: -0.011em; + -webkit-font-smoothing: antialiased; +} + +/* Code keeps its own mono family; only undo the inherited tracking. */ +.public-typography code, +.public-typography pre { + letter-spacing: normal; +} + +/* Pins the reading metrics that core.css otherwise takes from the Mantine + * type scale. rem, not px, so browser font-size preferences still apply. */ +.public-typography .ProseMirror { + font-size: 1rem; + line-height: 1.65; +} + +.public-typography .ProseMirror h1 { + letter-spacing: -0.021em; +} + +.public-typography .ProseMirror h2 { + letter-spacing: -0.017em; +} + +.public-typography .ProseMirror h3, +.public-typography .ProseMirror h4 { + letter-spacing: -0.014em; +} + +.public-typography .page-title .ProseMirror h1 { + letter-spacing: -0.022em; +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index d1127bca9..e4361fb4b 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => { POSTHOG_HOST, POSTHOG_KEY, AI_VECTOR_DRIVER, + BETA_PUBLIC_SPACES, } = loadEnv(mode, envPath, ""); return { @@ -33,6 +34,7 @@ export default defineConfig(({ mode }) => { POSTHOG_HOST, POSTHOG_KEY, AI_VECTOR_DRIVER, + BETA_PUBLIC_SPACES, }, APP_VERSION: JSON.stringify(process.env.npm_package_version), }, diff --git a/apps/server/package.json b/apps/server/package.json index 94463aacc..bc5f6543d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,6 +43,7 @@ "@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", @@ -160,7 +161,8 @@ "moduleFileExtensions": [ "js", "json", - "ts" + "ts", + "tsx" ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", @@ -180,7 +182,7 @@ ] } ], - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.(t|j)sx?$": "ts-jest" }, "transformIgnorePatterns": [ "/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))" diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 2de94a662..5002ca8a7 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -28,6 +28,7 @@ import { LoggerModule } from './common/logger/logger.module'; import { ClsModule } from 'nestjs-cls'; import { NoopAuditModule } from './integrations/audit/audit.module'; import { ThrottleModule } from './integrations/throttle/throttle.module'; +import { OutboundModule } from './integrations/outbound/outbound.module'; import { EncryptionModule } from './integrations/encryption/encryption.module'; const enterpriseModules = []; @@ -51,7 +52,7 @@ try { middleware: { mount: true }, }), LoggerModule, - NoopAuditModule, + ...(enterpriseModules.length > 0 ? [] : [NoopAuditModule]), CoreModule, DatabaseModule, EnvironmentModule, @@ -98,6 +99,7 @@ try { SecurityModule, TelemetryModule, ThrottleModule, + OutboundModule, ...enterpriseModules, ], controllers: [AppController], diff --git a/apps/server/src/collaboration/collaboration.gateway.ts b/apps/server/src/collaboration/collaboration.gateway.ts index 05536664e..254b8d430 100644 --- a/apps/server/src/collaboration/collaboration.gateway.ts +++ b/apps/server/src/collaboration/collaboration.gateway.ts @@ -63,6 +63,7 @@ export class CollaborationGateway { redis: new RedisClient({ host: this.redisConfig.host, port: this.redisConfig.port, + username: this.redisConfig.username, password: this.redisConfig.password, db: this.redisConfig.db, family: this.redisConfig.family, diff --git a/apps/server/src/collaboration/server/collab-app.module.ts b/apps/server/src/collaboration/server/collab-app.module.ts index 85738d1cb..da48bfb94 100644 --- a/apps/server/src/collaboration/server/collab-app.module.ts +++ b/apps/server/src/collaboration/server/collab-app.module.ts @@ -14,7 +14,8 @@ import { RedisModule } from '@nestjs-labs/nestjs-ioredis'; import { RedisConfigService } from '../../integrations/redis/redis-config.service'; import { CaslModule } from '../../core/casl/casl.module'; import { CacheModule } from '@nestjs/cache-manager'; -import KeyvRedis from '@keyv/redis'; +import KeyvRedis, { defaultReconnectStrategy } from '@keyv/redis'; +import { parseRedisUrl } from '../../common/helpers'; @Module({ imports: [ @@ -33,10 +34,20 @@ import KeyvRedis from '@keyv/redis'; isGlobal: true, useFactory: async (environmentService: EnvironmentService) => { const redisUrl = environmentService.getRedisUrl(); + const { family, tls } = parseRedisUrl(redisUrl); return { ttl: 5 * 1000, - stores: [new KeyvRedis(redisUrl)], + stores: [ + new KeyvRedis({ + url: redisUrl, + socket: { + family, + reconnectStrategy: defaultReconnectStrategy, + ...tls, + }, + }), + ], }; }, inject: [EnvironmentService], diff --git a/apps/server/src/common/decorators/oauth-scope.decorator.ts b/apps/server/src/common/decorators/oauth-scope.decorator.ts new file mode 100644 index 000000000..060cc3404 --- /dev/null +++ b/apps/server/src/common/decorators/oauth-scope.decorator.ts @@ -0,0 +1,8 @@ +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); diff --git a/apps/server/src/common/decorators/require-session-auth.decorator.ts b/apps/server/src/common/decorators/require-session-auth.decorator.ts new file mode 100644 index 000000000..b5498ad74 --- /dev/null +++ b/apps/server/src/common/decorators/require-session-auth.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const REQUIRE_SESSION_AUTH_KEY = 'requireSessionAuth'; + +export const RequireSessionAuth = () => + SetMetadata(REQUIRE_SESSION_AUTH_KEY, true); diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts index d8be76f83..7d088791b 100644 --- a/apps/server/src/common/events/audit-events.ts +++ b/apps/server/src/common/events/audit-events.ts @@ -14,6 +14,7 @@ export const AuditEvent = { USER_ROLE_CHANGED: 'user.role_changed', USER_PASSWORD_CHANGED: 'user.password_changed', USER_PASSWORD_RESET: 'user.password_reset', + USER_PASSWORD_RESET_REQUESTED: 'user.password_reset_requested', USER_UPDATED: 'user.updated', USER_DEACTIVATED: 'user.deactivated', USER_ACTIVATED: 'user.activated', @@ -23,6 +24,11 @@ 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', @@ -64,6 +70,7 @@ export const AuditEvent = { PAGE_RESTRICTION_REMOVED: 'page.restriction_removed', PAGE_PERMISSION_ADDED: 'page.permission_added', PAGE_PERMISSION_REMOVED: 'page.permission_removed', + PAGE_PERMISSION_ROLE_CHANGED: 'page.permission_role_changed', // Page verification PAGE_VERIFICATION_CREATED: 'page.verification_created', PAGE_VERIFICATION_UPDATED: 'page.verification_updated', @@ -99,6 +106,16 @@ export const AuditEvent = { // Attachment ATTACHMENT_UPLOADED: 'attachment.uploaded', // ATTACHMENT_DELETED: 'attachment.deleted', + + // SIEM streaming + SIEM_DESTINATION_CREATED: 'siem_destination.created', + SIEM_DESTINATION_UPDATED: 'siem_destination.updated', + SIEM_DESTINATION_DELETED: 'siem_destination.deleted', + SIEM_DESTINATION_TEST: 'siem_destination.test', + + // Template + TEMPLATE_CREATED: 'template.created', + TEMPLATE_DELETED: 'template.deleted', } as const; export type AuditEventType = (typeof AuditEvent)[keyof typeof AuditEvent]; @@ -111,7 +128,8 @@ export const EXCLUDED_AUDIT_EVENTS: Set = new Set([ AuditEvent.COMMENT_UPDATED, AuditEvent.COMMENT_RESOLVED, AuditEvent.COMMENT_REOPENED, - AuditEvent.ATTACHMENT_UPLOADED + AuditEvent.ATTACHMENT_UPLOADED, + AuditEvent.SIEM_DESTINATION_TEST, ]); export const AuditResource = { @@ -124,11 +142,15 @@ 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 = diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts index 3a0ecba17..b52a3b722 100644 --- a/apps/server/src/common/events/event.contants.ts +++ b/apps/server/src/common/events/event.contants.ts @@ -12,6 +12,8 @@ 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', diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts index 2a889fd08..ac446d325 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -23,6 +23,11 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + OAUTH: 'oauth', + AI_CONTROLS: 'ai:controls', + MCP_CONTROLS: 'mcp:controls', + PUBLIC_SPACE_APPEARANCE: 'public-space:appearance', + SIEM: 'siem', } as const; export type FeatureKey = (typeof Feature)[keyof typeof Feature]; diff --git a/apps/server/src/common/guards/jwt-auth.guard.spec.ts b/apps/server/src/common/guards/jwt-auth.guard.spec.ts new file mode 100644 index 000000000..113ab78f5 --- /dev/null +++ b/apps/server/src/common/guards/jwt-auth.guard.spec.ts @@ -0,0 +1,186 @@ +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([ + [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'); + }); +}); diff --git a/apps/server/src/common/guards/jwt-auth.guard.ts b/apps/server/src/common/guards/jwt-auth.guard.ts index 0508ed4ef..92a51402e 100644 --- a/apps/server/src/common/guards/jwt-auth.guard.ts +++ b/apps/server/src/common/guards/jwt-auth.guard.ts @@ -1,16 +1,26 @@ 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, @@ -36,6 +46,39 @@ export class JwtAuthGuard extends AuthGuard('jwt') { throw err || new UnauthorizedException(); } + const requiresSession = this.reflector.getAllAndOverride( + 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; } diff --git a/apps/server/src/common/helpers/cache-keys.ts b/apps/server/src/common/helpers/cache-keys.ts index 38b24d20e..394ab173f 100644 --- a/apps/server/src/common/helpers/cache-keys.ts +++ b/apps/server/src/common/helpers/cache-keys.ts @@ -4,6 +4,7 @@ export const CacheKey = { `perm:space-roles:${userId}:${spaceId}`, PAGE_CAN_EDIT: (userId: string, pageId: string) => `perm:can-edit:${userId}:${pageId}`, + SIEM_LICENSED: (workspaceId: string) => `siem:licensed:${workspaceId}`, }; // Permission caches dedupe repeated checks within and across short request bursts. diff --git a/apps/server/src/common/helpers/security-headers.spec.ts b/apps/server/src/common/helpers/security-headers.spec.ts new file mode 100644 index 000000000..cf098b839 --- /dev/null +++ b/apps/server/src/common/helpers/security-headers.spec.ts @@ -0,0 +1,66 @@ +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([]); + }); +}); diff --git a/apps/server/src/common/helpers/security-headers.ts b/apps/server/src/common/helpers/security-headers.ts index 931300e2d..235c9258f 100644 --- a/apps/server/src/common/helpers/security-headers.ts +++ b/apps/server/src/common/helpers/security-headers.ts @@ -17,3 +17,19 @@ 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] : []; +} diff --git a/apps/server/src/common/helpers/utils.ts b/apps/server/src/common/helpers/utils.ts index 100d55d92..aebe05385 100644 --- a/apps/server/src/common/helpers/utils.ts +++ b/apps/server/src/common/helpers/utils.ts @@ -28,6 +28,7 @@ export type RedisConfig = { host: string; port: number; db: number; + username?: string; password?: string; family?: number; tls?: { rejectUnauthorized?: boolean }; @@ -36,7 +37,15 @@ export type RedisConfig = { export function parseRedisUrl(redisUrl: string): RedisConfig { // format - redis[s]://[[username][:password]@][host][:port][/db-number][?family=4|6][&rejectUnauthorized=false] const url = new URL(redisUrl); - const { hostname, port, password, pathname, protocol, searchParams } = url; + const { + hostname, + port, + username, + password, + pathname, + protocol, + searchParams, + } = url; const portInt = port ? parseInt(port, 10) : 6379; let db: number = 0; @@ -62,7 +71,15 @@ export function parseRedisUrl(redisUrl: string): RedisConfig { : {} : undefined; - return { host: hostname, port: portInt, password: password || undefined, db, family, tls }; + return { + host: hostname, + port: portInt, + username: username ? decodeURIComponent(username) : undefined, + password: password ? decodeURIComponent(password) : undefined, + db, + family, + tls, + }; } export function createRetryStrategy() { diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index 57d4124c3..13e92901f 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -24,6 +24,7 @@ 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 { @@ -175,6 +176,7 @@ export class AttachmentController { @UseGuards(JwtAuthGuard) @Get('/files/:fileId/:fileName') + @OAuthScope('read') async getFile( @Req() req: FastifyRequest, @Res() res: FastifyReply, @@ -427,6 +429,7 @@ export class AttachmentController { @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('pages/attachments') + @OAuthScope('read') async getPageAttachments( @Body() dto: PageIdDto, @Body() pagination: PaginationOptions, diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index 710f9d10b..2db13c817 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -116,8 +116,8 @@ export class AttachmentService { }); } - // Only index PDFs and DOCX files - if (['.pdf', '.docx'].includes(attachment.fileExt.toLowerCase())) { + // Only index PDF, DOCX and TXT files + if (['.pdf', '.docx', '.txt'].includes(attachment.fileExt.toLowerCase())) { await this.attachmentQueue.add( QueueJob.ATTACHMENT_INDEX_CONTENT, { diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 89bb9e1b0..08b7a6303 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -12,7 +12,7 @@ import { } from '@nestjs/common'; import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler'; import { - AI_CHAT_THROTTLER, + ALL_NAMED_THROTTLERS_SKIPPED, 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({ [AI_CHAT_THROTTLER]: true }) +@SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [AUTH_THROTTLER]: false }) @UseGuards(ThrottlerGuard) @Controller('auth') export class AuthController { diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts index b3ccda709..4c249ccb1 100644 --- a/apps/server/src/core/auth/dto/jwt-payload.ts +++ b/apps/server/src/core/auth/dto/jwt-payload.ts @@ -7,6 +7,7 @@ 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; @@ -59,3 +60,14 @@ 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; +}; diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index bfd8e1a08..303245045 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, + Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -40,9 +41,13 @@ 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, @@ -53,6 +58,7 @@ 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, ) {} @@ -213,6 +219,13 @@ export class AuthService { subject: 'Reset your password', template: emailTemplate, }); + + this.auditService.log({ + event: AuditEvent.USER_PASSWORD_RESET_REQUESTED, + resourceType: AuditResource.USER, + resourceId: user.id, + metadata: { source: 'forgot_password' }, + }); } async passwordReset( @@ -261,6 +274,19 @@ 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, diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index d861eddf0..c74fa3e0c 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -2,7 +2,12 @@ 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, JwtPayload, JwtType } from '../dto/jwt-payload'; +import { + JwtApiKeyPayload, + JwtOAuthPayload, + 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'; @@ -33,7 +38,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { }); } - async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) { + async validate( + req: any, + payload: JwtPayload | JwtApiKeyPayload | JwtOAuthPayload, + ) { if (!payload.workspaceId) { throw new UnauthorizedException(); } @@ -42,8 +50,21 @@ 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) { - return this.validateApiKey(req, payload as JwtApiKeyPayload); + 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 }; } if (payload.type !== JwtType.ACCESS) { @@ -71,7 +92,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId); } - return { user, workspace }; + return { user, workspace, authType: JwtType.ACCESS }; } private async validateApiKey(req: any, payload: JwtApiKeyPayload) { @@ -99,4 +120,36 @@ 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'); + } } diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 22458848b..3902c5846 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -16,6 +16,7 @@ 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'; @@ -48,13 +49,14 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post('create') + @OAuthScope('write') async create( @Body() createCommentDto: CreateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { const page = await this.pageRepo.findById(createCommentDto.pageId); - if (!page || page.deletedAt) { + if (!page || page.workspaceId !== workspace.id || page.deletedAt) { throw new NotFoundException('Page not found'); } @@ -84,14 +86,16 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post('/') + @OAuthScope('read') async findPageComments( @Body() input: PageIdDto, @Body() pagination: PaginationOptions, @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, ) { const page = await this.pageRepo.findById(input.pageId); - if (!page) { + if (!page || page.workspaceId !== workspace.id || page.deletedAt) { throw new NotFoundException('Page not found'); } @@ -102,14 +106,18 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post('info') - async findOne(@Body() input: CommentIdDto, @AuthUser() user: User) { + async findOne( + @Body() input: CommentIdDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { const comment = await this.commentRepo.findById(input.commentId); if (!comment) { throw new NotFoundException('Comment not found'); } const page = await this.pageRepo.findById(comment.pageId); - if (!page) { + if (!page || page.workspaceId !== workspace.id || page.deletedAt) { throw new NotFoundException('Page not found'); } @@ -120,6 +128,7 @@ 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, @@ -130,7 +139,7 @@ export class CommentController { } const page = await this.pageRepo.findById(comment.pageId); - if (!page) { + if (!page || page.workspaceId !== workspace.id || page.deletedAt) { throw new NotFoundException('Page not found'); } @@ -148,7 +157,7 @@ export class CommentController { } const page = await this.pageRepo.findById(comment.pageId); - if (!page) { + if (!page || page.workspaceId !== workspace.id || page.deletedAt) { throw new NotFoundException('Page not found'); } diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index e898a4a1c..5bd9be1c1 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -18,6 +18,7 @@ import { PageAccessModule } from './page/page-access/page-access.module'; import { DomainMiddleware } from '../common/middlewares/domain.middleware'; import { AuditContextMiddleware } from '../common/middlewares/audit-context.middleware'; import { ShareModule } from './share/share.module'; +import { PublicSpaceModule } from './public-space/public-space.module'; import { LabelModule } from './label/label.module'; import { NotificationModule } from './notification/notification.module'; import { WatcherModule } from './watcher/watcher.module'; @@ -40,6 +41,7 @@ import { ClsMiddleware } from 'nestjs-cls'; CaslModule, PageAccessModule, ShareModule, + PublicSpaceModule, LabelModule, NotificationModule, WatcherModule, diff --git a/apps/server/src/core/favorite/favorite.controller.ts b/apps/server/src/core/favorite/favorite.controller.ts index 47e63aace..2d6c19398 100644 --- a/apps/server/src/core/favorite/favorite.controller.ts +++ b/apps/server/src/core/favorite/favorite.controller.ts @@ -48,9 +48,9 @@ export class FavoriteController { await this.favoriteService.addFavorite(user.id, workspace.id, { type: dto.type, - pageId: dto.pageId, + pageId: dto.type === 'page' ? dto.pageId : undefined, spaceId: dto.type === 'space' ? resolved.spaceId : undefined, - templateId: dto.templateId, + templateId: dto.type === 'template' ? dto.templateId : undefined, }); } diff --git a/apps/server/src/core/favorite/services/favorite.service.ts b/apps/server/src/core/favorite/services/favorite.service.ts index 79902e645..ff10cb80a 100644 --- a/apps/server/src/core/favorite/services/favorite.service.ts +++ b/apps/server/src/core/favorite/services/favorite.service.ts @@ -6,14 +6,12 @@ import { import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { InsertableFavorite } from '@docmost/db/types/entity.types'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; -import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; @Injectable() export class FavoriteService { constructor( private readonly favoriteRepo: FavoriteRepo, private readonly pagePermissionRepo: PagePermissionRepo, - private readonly spaceMemberRepo: SpaceMemberRepo, ) {} async getFavoriteIds( @@ -43,12 +41,6 @@ export class FavoriteService { result.items = result.items.filter((id) => accessibleSet.has(id)); } - if (type === FavoriteType.SPACE) { - const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); - const spaceSet = new Set(userSpaceIds); - result.items = result.items.filter((id) => spaceSet.has(id)); - } - return result; } @@ -111,9 +103,6 @@ export class FavoriteService { return result; } - const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId); - const spaceSet = new Set(userSpaceIds); - const pageFavorites = result.items.filter( (f) => f.type === FavoriteType.PAGE && f.pageId, ); @@ -129,19 +118,11 @@ export class FavoriteService { accessiblePageSet = new Set(accessibleIds); } - result.items = result.items.filter((f) => { - if (f.type === FavoriteType.PAGE) { - return f.pageId && accessiblePageSet?.has(f.pageId); - } - if (f.type === FavoriteType.SPACE) { - return f.spaceId && spaceSet.has(f.spaceId); - } - if (f.type === FavoriteType.TEMPLATE) { - const templateSpaceId = (f as any).template?.spaceId; - return !templateSpaceId || spaceSet.has(templateSpaceId); - } - return true; - }); + result.items = result.items.filter( + (f) => + f.type !== FavoriteType.PAGE || + (f.pageId && accessiblePageSet?.has(f.pageId)), + ); return result; } diff --git a/apps/server/src/core/label/label.service.ts b/apps/server/src/core/label/label.service.ts index f0d63eba6..9ec0ab300 100644 --- a/apps/server/src/core/label/label.service.ts +++ b/apps/server/src/core/label/label.service.ts @@ -7,12 +7,15 @@ import { executeTx } from '@docmost/db/utils'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { normalizeLabelName } from './utils'; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { EventName } from "src/common/events/event.contants"; @Injectable() export class LabelService { constructor( private readonly labelRepo: LabelRepo, private readonly pagePermissionRepo: PagePermissionRepo, + private readonly eventEmitter: EventEmitter2, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -34,6 +37,12 @@ export class LabelService { attached.push(label); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); + return attached; } @@ -64,6 +73,11 @@ export class LabelService { await this.labelRepo.deleteLabel(labelId, workspaceId, trx); } }); + + this.eventEmitter.emit(EventName.PAGE_UPDATED, { + pageIds: [pageId], + workspaceId: workspaceId, + }); } async getPageLabels(pageId: string, pagination: PaginationOptions) { diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts index fc42bc64d..894b98e4c 100644 --- a/apps/server/src/core/notification/notification.constants.ts +++ b/apps/server/src/core/notification/notification.constants.ts @@ -10,6 +10,9 @@ export const NotificationType = { PAGE_VERIFIED: 'page.verified', PAGE_APPROVAL_REQUESTED: 'page.approval_requested', PAGE_APPROVAL_REJECTED: 'page.approval_rejected', + SIEM_DESTINATION_FAILING: 'siem_destination.failing', + SIEM_DESTINATION_DISABLED: 'siem_destination.disabled', + SIEM_DESTINATION_RECOVERED: 'siem_destination.recovered', } as const; export type NotificationType = @@ -40,6 +43,9 @@ export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [ NotificationType.COMMENT_RESOLVED, NotificationType.PAGE_USER_MENTION, NotificationType.PAGE_PERMISSION_GRANTED, + NotificationType.SIEM_DESTINATION_FAILING, + NotificationType.SIEM_DESTINATION_DISABLED, + NotificationType.SIEM_DESTINATION_RECOVERED, ]; export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [ diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts index 773774ea7..397eed259 100644 --- a/apps/server/src/core/page/page.controller.ts +++ b/apps/server/src/core/page/page.controller.ts @@ -26,6 +26,7 @@ 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'; @@ -69,6 +70,7 @@ 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, @@ -199,6 +201,7 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('create') + @OAuthScope('write') async create( @Body() createPageDto: CreatePageDto, @AuthUser() user: User, @@ -269,6 +272,7 @@ 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); @@ -413,6 +417,7 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('recent') + @OAuthScope('read') async getRecentPages( @Body() recentPageDto: RecentPageDto, @Body() pagination: PaginationOptions, @@ -527,6 +532,7 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('/sidebar-pages') + @OAuthScope('read') async getSidebarPages( @Body() dto: SidebarPageDto, @Body() pagination: PaginationOptions, @@ -569,6 +575,7 @@ export class PageController { @HttpCode(HttpStatus.OK) @Post('move-to-space') + @OAuthScope('write') async movePageToSpace( @Body() dto: MovePageToSpaceDto, @AuthUser() user: User, @@ -622,6 +629,7 @@ 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) { @@ -706,6 +714,7 @@ 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) { diff --git a/apps/server/src/core/public-space/dto/public-space.dto.ts b/apps/server/src/core/public-space/dto/public-space.dto.ts new file mode 100644 index 000000000..8a27fc4b5 --- /dev/null +++ b/apps/server/src/core/public-space/dto/public-space.dto.ts @@ -0,0 +1,79 @@ +import { + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { LookupDto } from '../../page/transclusion/dto/lookup.dto'; + +export const APPEARANCE_HEX_REGEX = /^#[0-9a-fA-F]{6}$/; + +export class PublicSpaceSlugDto { + @IsString() + @IsNotEmpty() + spaceSlug: string; +} + +export class PublicSpacePageDto extends PublicSpaceSlugDto { + @IsString() + @IsOptional() + pageSlugId?: string; + + @IsOptional() + @IsBoolean() + contentless?: boolean; +} + +export class PublicSpaceTransclusionLookupDto extends LookupDto { + @IsString() + @IsNotEmpty() + spaceSlug!: string; +} + +export class PublicSpaceAppearanceDto { + @IsOptional() + @Matches(APPEARANCE_HEX_REGEX) + primaryColorLight?: string | null; + + @IsOptional() + @Matches(APPEARANCE_HEX_REGEX) + primaryColorDark?: string | null; +} + +export class PublishSpaceDto { + @IsUUID() + spaceId: string; + + @IsBoolean() + enabled: boolean; + + @IsOptional() + @IsBoolean() + searchIndexing?: boolean; + + @IsOptional() + @ValidateNested() + @Type(() => PublicSpaceAppearanceDto) + appearance?: PublicSpaceAppearanceDto; + + @IsOptional() + @IsBoolean() + bylineAuthor?: boolean; + + @IsOptional() + @IsBoolean() + bylineUpdatedAt?: boolean; + + @IsOptional() + @IsBoolean() + directory?: boolean; +} + +export class PublicSpaceForSpaceDto { + @IsUUID() + spaceId: string; +} diff --git a/apps/server/src/core/public-space/public-space-seo.controller.ts b/apps/server/src/core/public-space/public-space-seo.controller.ts new file mode 100644 index 000000000..f35ecc5e5 --- /dev/null +++ b/apps/server/src/core/public-space/public-space-seo.controller.ts @@ -0,0 +1,173 @@ +import { Controller, Get, Logger, Param, Req, Res } from '@nestjs/common'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { join } from 'path'; +import * as fs from 'node:fs'; +import { validate as isValidUUID } from 'uuid'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { Workspace } from '@docmost/db/types/entity.types'; +import { htmlEscape } from '../../common/helpers/html-escaper'; +import { PublicSpaceService } from './public-space.service'; + +@Controller('docs') +export class PublicSpaceSeoController { + private readonly logger = new Logger(PublicSpaceSeoController.name); + + constructor( + private readonly publicSpaceService: PublicSpaceService, + private workspaceRepo: WorkspaceRepo, + private environmentService: EnvironmentService, + ) {} + + /* + * The /docs hub: inject meta only when the directory is enabled; + * otherwise the untouched SPA shell (uniform with 404s). + */ + @Get() + async getDirectoryHub( + @Res({ passthrough: false }) res: FastifyReply, + @Req() req: FastifyRequest, + ) { + const workspace = await this.resolveWorkspace(req); + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + if (!fs.existsSync(clientDistPath)) { + return; + } + const indexFilePath = join(clientDistPath, 'index.html'); + + if (!workspace) { + return this.sendIndex(indexFilePath, res); + } + + try { + await this.publicSpaceService.getPublicSpaceDirectory(workspace); + } catch (err) { + return this.sendIndex(indexFilePath, res); + } + + const metaTitle = 'Documentation'; + const metaTagVar = ''; + const metaTags = ``; + + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); + + res.type('text/html').send(transformedHtml); + } + + /* + * add meta tags to public space pages + */ + @Get([':spaceSlug', ':spaceSlug/:pageSlug']) + async getPublicSpacePage( + @Res({ passthrough: false }) res: FastifyReply, + @Req() req: FastifyRequest, + @Param('spaceSlug') spaceSlug: string, + @Param('pageSlug') pageSlug: string, + ) { + const workspace = await this.resolveWorkspace(req); + + const clientDistPath = join( + __dirname, + '..', + '..', + '..', + '..', + 'client/dist', + ); + + if (!fs.existsSync(clientDistPath)) { + return; + } + const indexFilePath = join(clientDistPath, 'index.html'); + + if (!workspace) { + return this.sendIndex(indexFilePath, res); + } + + let title: string = null; + let searchIndexing = false; + + try { + if (pageSlug) { + const pageSlugId = this.extractPageSlugId(pageSlug); + const pageData = await this.publicSpaceService.getPublicPage( + spaceSlug, + pageSlugId, + workspace, + { includeContent: false }, + ); + title = pageData.page?.title ?? pageData.space.name; + searchIndexing = pageData.searchIndexing; + } else { + const info = await this.publicSpaceService.getPublicSpaceInfo( + spaceSlug, + workspace, + ); + title = info.space.name; + searchIndexing = info.searchIndexing; + } + } catch (err) { + // Not public: serve the untouched SPA shell with zero injected meta. + this.logger.debug(`no public meta for ${spaceSlug}`); + return this.sendIndex(indexFilePath, res); + } + + const rawTitle = htmlEscape(title ?? 'untitled'); + const metaTitle = + rawTitle.length > 80 ? `${rawTitle.slice(0, 77)}…` : rawTitle; + + const metaTagVar = ''; + const metaTags = [ + ``, + ``, + !searchIndexing ? `` : '', + ] + .filter(Boolean) + .join('\n '); + + const html = fs.readFileSync(indexFilePath, 'utf8'); + const transformedHtml = html + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); + + res.type('text/html').send(transformedHtml); + } + + // Prefix-excluded routes skip middleware, so resolve the workspace inline + // exactly like ShareSeoController does. + private async resolveWorkspace(req: FastifyRequest): Promise { + if (this.environmentService.isSelfHosted()) { + return this.workspaceRepo.findFirst(); + } + const header = req.raw.headers.host; + const subdomain = header.split('.')[0]; + return this.workspaceRepo.findByHostname(subdomain); + } + + sendIndex(indexFilePath: string, res: FastifyReply) { + const stream = fs.createReadStream(indexFilePath); + res.type('text/html').send(stream); + } + + extractPageSlugId(slug: string): string { + if (!slug) { + return undefined; + } + if (isValidUUID(slug)) { + return slug; + } + const parts = slug.split('-'); + return parts.length > 1 ? parts[parts.length - 1] : slug; + } +} diff --git a/apps/server/src/core/public-space/public-space.controller.ts b/apps/server/src/core/public-space/public-space.controller.ts new file mode 100644 index 000000000..d60ebc8d9 --- /dev/null +++ b/apps/server/src/core/public-space/public-space.controller.ts @@ -0,0 +1,249 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + Inject, + NotFoundException, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Public } from '../../common/decorators/public.decorator'; +import { PublicSpaceService } from './public-space.service'; +import { + PublicSpaceForSpaceDto, + PublicSpacePageDto, + PublicSpaceSlugDto, + PublicSpaceTransclusionLookupDto, + PublishSpaceDto, +} from './dto/public-space.dto'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; +import { + SpaceCaslAction, + SpaceCaslSubject, +} from '../casl/interfaces/space-ability.type'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { Feature, FeatureKey } from '../../common/features'; + +const PUBLIC_SPACE_FEATURES: FeatureKey[] = [Feature.PUBLIC_SPACE_APPEARANCE]; + +@UseGuards(JwtAuthGuard) +@Controller('public-spaces') +export class PublicSpaceController { + constructor( + private readonly publicSpaceService: PublicSpaceService, + private readonly publicSpaceRepo: PublicSpaceRepo, + private readonly spaceRepo: SpaceRepo, + private readonly spaceAbility: SpaceAbilityFactory, + private readonly licenseCheckService: LicenseCheckService, + private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + private assertBetaPublicSpaces() { + if (!this.environmentService.isBetaPublicSpaces()) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + } + + @HttpCode(HttpStatus.OK) + @Post('/') + async getPublishedSpaces( + @Body() pagination: PaginationOptions, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + this.assertBetaPublicSpaces(); + return this.publicSpaceRepo.getPublishedSpaces( + user.id, + workspace.id, + pagination, + ); + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/info') + async getInfo( + @Body() dto: PublicSpaceSlugDto, + @AuthWorkspace() workspace: Workspace, + ) { + const info = await this.publicSpaceService.getPublicSpaceInfo( + dto.spaceSlug, + workspace, + ); + return { + ...info, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/directory') + async getDirectory(@AuthWorkspace() workspace: Workspace) { + const directory = + await this.publicSpaceService.getPublicSpaceDirectory(workspace); + return { + ...directory, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/tree') + async getTree( + @Body() dto: PublicSpaceSlugDto, + @AuthWorkspace() workspace: Workspace, + ) { + const treeData = await this.publicSpaceService.getPublicSpaceTree( + dto.spaceSlug, + workspace, + ); + return { + ...treeData, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/page-info') + async getPageInfo( + @Body() dto: PublicSpacePageDto, + @AuthWorkspace() workspace: Workspace, + ) { + const pageData = await this.publicSpaceService.getPublicPage( + dto.spaceSlug, + dto.pageSlugId, + workspace, + { includeContent: dto.contentless !== true }, + ); + return { + ...pageData, + features: this.publicFeatures(workspace), + }; + } + + @Public() + @HttpCode(HttpStatus.OK) + @Post('/transclusion/lookup') + async transclusionLookup( + @Body() dto: PublicSpaceTransclusionLookupDto, + @AuthWorkspace() workspace: Workspace, + ) { + return this.publicSpaceService.lookupTransclusionForPublicSpace( + dto.spaceSlug, + dto.references, + workspace, + ); + } + + private publicFeatures(workspace: Workspace): string[] { + const features = this.licenseCheckService.resolveFeatures( + workspace.licenseKey, + workspace.plan, + ); + return PUBLIC_SPACE_FEATURES.filter((feature) => + features.includes(feature), + ); + } + + @HttpCode(HttpStatus.OK) + @Post('/for-space') + async getForSpace( + @Body() dto: PublicSpaceForSpaceDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + this.assertBetaPublicSpaces(); + const space = await this.spaceRepo.findById(dto.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const ability = await this.spaceAbility.createForUser(user, space.id); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException(); + } + + return (await this.publicSpaceRepo.findBySpaceId(space.id)) ?? null; + } + + @HttpCode(HttpStatus.OK) + @Post('/publish') + async publish( + @Body() dto: PublishSpaceDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const space = await this.spaceRepo.findById(dto.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const ability = await this.spaceAbility.createForUser(user, space.id); + if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { + throw new ForbiddenException(); + } + + const prev = await this.publicSpaceRepo.findBySpaceId(space.id); + + const publicSpace = await this.publicSpaceService.publish({ + space, + workspace, + authUserId: user.id, + enabled: dto.enabled, + searchIndexing: dto.searchIndexing, + appearance: dto.appearance, + bylineAuthor: dto.bylineAuthor, + bylineUpdatedAt: dto.bylineUpdatedAt, + directory: dto.directory, + }); + + const prevByline = (prev?.settings as any)?.byline; + const nextSettings = publicSpace?.settings as any; + + this.auditService.log({ + event: AuditEvent.SPACE_UPDATED, + resourceType: AuditResource.SPACE, + resourceId: space.id, + spaceId: space.id, + changes: { + before: { + isPublished: prev?.enabled ?? false, + searchIndexing: prev?.searchIndexing === true, + bylineAuthor: prevByline?.author === true, + bylineUpdatedAt: prevByline?.updatedAt !== false, + directory: (prev?.settings as any)?.directory === true, + }, + after: { + isPublished: publicSpace?.enabled ?? dto.enabled, + searchIndexing: publicSpace?.searchIndexing === true, + bylineAuthor: nextSettings?.byline?.author === true, + bylineUpdatedAt: nextSettings?.byline?.updatedAt !== false, + directory: nextSettings?.directory === true, + }, + }, + }); + + return publicSpace; + } +} diff --git a/apps/server/src/core/public-space/public-space.module.ts b/apps/server/src/core/public-space/public-space.module.ts new file mode 100644 index 000000000..466cc2055 --- /dev/null +++ b/apps/server/src/core/public-space/public-space.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PublicSpaceController } from './public-space.controller'; +import { PublicSpaceSeoController } from './public-space-seo.controller'; +import { PublicSpaceService } from './public-space.service'; +import { ShareModule } from '../share/share.module'; +import { TransclusionModule } from '../page/transclusion/transclusion.module'; + +@Module({ + imports: [ShareModule, TransclusionModule], + controllers: [PublicSpaceController, PublicSpaceSeoController], + providers: [PublicSpaceService], + exports: [PublicSpaceService], +}) +export class PublicSpaceModule {} diff --git a/apps/server/src/core/public-space/public-space.service.spec.ts b/apps/server/src/core/public-space/public-space.service.spec.ts new file mode 100644 index 000000000..794f93f8c --- /dev/null +++ b/apps/server/src/core/public-space/public-space.service.spec.ts @@ -0,0 +1,1103 @@ +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { PublicSpaceService } from './public-space.service'; +import { Feature } from '../../common/features'; + +const WORKSPACE_ID = '018f0000-0000-7000-8000-000000000001'; +const SPACE_ID = '018f0000-0000-7000-8000-000000000002'; + +function makeWorkspace(settings: any = {}) { + return { id: WORKSPACE_ID, settings } as any; +} + +const optInSettings = { publicSpaces: { enabled: true } }; + +function makeService(overrides: any = {}) { + const publicSpaceRepo = { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + }), + upsert: jest.fn().mockImplementation(async (opts) => opts), + ...overrides.publicSpaceRepo, + }; + const spaceRepo = { + findBySlug: jest.fn().mockResolvedValue({ + id: SPACE_ID, + slug: 'handbook', + name: 'Handbook', + workspaceId: WORKSPACE_ID, + deletedAt: null, + }), + ...overrides.spaceRepo, + }; + const pageRepo = { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + }), + getSpacePagesExcludingRestricted: jest.fn().mockResolvedValue([]), + getFirstUnrestrictedRootPage: jest + .fn() + .mockResolvedValue({ id: 'page1', slugId: 'abc123XYZ0' }), + ...overrides.pageRepo, + }; + const pagePermissionRepo = { + hasRestrictedAncestor: jest.fn().mockResolvedValue(false), + ...overrides.pagePermissionRepo, + }; + const shareService = { + updatePublicAttachments: jest.fn().mockImplementation(async (p) => p.content), + sanitizeTransclusionItemsForPublic: jest + .fn() + .mockImplementation(async (items) => items), + ...overrides.shareService, + }; + const transclusionService = { + lookupWithAccessSet: jest.fn().mockResolvedValue({ items: [] }), + ...overrides.transclusionService, + }; + + const licenseCheckService = { + resolveFeatures: jest.fn().mockReturnValue([Feature.PUBLIC_SPACE_APPEARANCE]), + ...overrides.licenseCheckService, + }; + + const environmentService = { + isBetaPublicSpaces: jest.fn().mockReturnValue(true), + ...overrides.environmentService, + }; + + const service = new PublicSpaceService( + publicSpaceRepo as any, + spaceRepo as any, + pageRepo as any, + pagePermissionRepo as any, + shareService as any, + transclusionService as any, + licenseCheckService as any, + environmentService as any, + ); + return { + service, + publicSpaceRepo, + spaceRepo, + pageRepo, + pagePermissionRepo, + shareService, + transclusionService, + licenseCheckService, + environmentService, + }; +} + +describe('PublicSpaceService', () => { + describe('getPublicSpace', () => { + it('404s when the workspace opt-in is off (default)', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpace('handbook', makeWorkspace({})), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when BETA_PUBLIC_SPACES is off even with the workspace opt-in on', async () => { + const { service, spaceRepo } = makeService({ + environmentService: { + isBetaPublicSpaces: jest.fn().mockReturnValue(false), + }, + }); + const ws = makeWorkspace({ publicSpaces: { enabled: true } }); + await expect(service.getPublicSpace('handbook', ws)).rejects.toThrow( + NotFoundException, + ); + expect(spaceRepo.findBySlug).not.toHaveBeenCalled(); + }); + + it('resolves when public page sharing is disabled', async () => { + const { service } = makeService(); + const ws = makeWorkspace({ + publicSpaces: { enabled: true }, + sharing: { disabled: true }, + }); + const result = await service.getPublicSpace('handbook', ws); + + expect(result.space.id).toBe(SPACE_ID); + expect(result.publicSpace.enabled).toBe(true); + }); + + it('404s when the space row is disabled', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ enabled: false }), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when there is no public_spaces row', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s when the space is deleted', async () => { + const { service } = makeService({ + spaceRepo: { + findBySlug: jest + .fn() + .mockResolvedValue({ id: SPACE_ID, deletedAt: new Date() }), + }, + }); + await expect( + service.getPublicSpace('handbook', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('resolves when opted in, enabled, and space exists', async () => { + const { service } = makeService(); + const result = await service.getPublicSpace( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.space.id).toBe(SPACE_ID); + expect(result.publicSpace.enabled).toBe(true); + }); + }); + + describe('getPublicPage', () => { + it('404s for a page belonging to another space', async () => { + const { service } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page2', + spaceId: 'other-space', + deletedAt: null, + }), + }, + }); + await expect( + service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('404s for a restricted page', async () => { + const { service } = makeService({ + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + await expect( + service.getPublicPage('handbook', 'abc123XYZ0', makeWorkspace(optInSettings)), + ).rejects.toThrow(NotFoundException); + }); + + it('resolves the first unrestricted root page as the home', async () => { + const { service, pageRepo } = makeService(); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(pageRepo.getFirstUnrestrictedRootPage).toHaveBeenCalledWith(SPACE_ID); + expect(result.page.id).toBe('page1'); + }); + + it('sanitizes the served page content for public delivery', async () => { + const sanitizedDoc = { type: 'doc', sanitized: true }; + const { service, shareService } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: { type: 'doc', content: [] }, + }), + }, + shareService: { + updatePublicAttachments: jest.fn().mockResolvedValue(sanitizedDoc), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(shareService.updatePublicAttachments.mock.calls[0][0]).toBe( + result.page, + ); + expect(result.page.content).toBe(sanitizedDoc); + }); + + it('returns page null for an empty published space', async () => { + const { service } = makeService({ + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(result.page).toBeNull(); + }); + + it('skips content preparation when includeContent is false', async () => { + const { service, pageRepo, shareService } = makeService(); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + { includeContent: false }, + ); + + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0'); + expect(shareService.updatePublicAttachments).not.toHaveBeenCalled(); + expect(result.page.id).toBe('page1'); + }); + }); + + describe('lookupTransclusionForPublicSpace', () => { + const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }]; + + it('excludes a candidate from another space', async () => { + const { service, transclusionService } = makeService({ + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page2', + spaceId: 'other-space', + deletedAt: null, + }), + }, + }); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual([]); + }); + + it('excludes a restricted candidate', async () => { + const { service, transclusionService } = makeService({ + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual([]); + }); + + it('includes a valid same-space candidate by its page id', async () => { + const { service, transclusionService } = makeService(); + + await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + const accessSet = transclusionService.lookupWithAccessSet.mock.calls[0][1]; + expect(Array.from(accessSet)).toEqual(['page1']); + }); + + it('delegates item sanitization to the share service', async () => { + const rawItems = [ + { sourcePageId: 'page1', transclusionId: 't1', status: 'not_found' }, + ]; + const sanitizedItems = [ + { sourcePageId: 'page1', transclusionId: 't1', status: 'no_access' }, + ]; + const { service, shareService } = makeService({ + transclusionService: { + lookupWithAccessSet: jest.fn().mockResolvedValue({ items: rawItems }), + }, + shareService: { + sanitizeTransclusionItemsForPublic: jest + .fn() + .mockResolvedValue(sanitizedItems), + }, + }); + + const result = await service.lookupTransclusionForPublicSpace( + 'handbook', + refs, + makeWorkspace(optInSettings), + ); + + expect( + shareService.sanitizeTransclusionItemsForPublic, + ).toHaveBeenCalledWith(rawItems, WORKSPACE_ID); + expect(result.items).toBe(sanitizedItems); + }); + }); + + describe('BETA_PUBLIC_SPACES off', () => { + const flagOff = { + environmentService: { + isBetaPublicSpaces: jest.fn().mockReturnValue(false), + }, + }; + const refs = [{ sourcePageId: 'ref1', transclusionId: 't1' }]; + + it.each([ + ['getPublicSpaceInfo', (s, ws) => s.getPublicSpaceInfo('handbook', ws)], + ['getPublicSpaceTree', (s, ws) => s.getPublicSpaceTree('handbook', ws)], + [ + 'getPublicPage', + (s, ws) => s.getPublicPage('handbook', 'abc123XYZ0', ws), + ], + [ + 'lookupTransclusionForPublicSpace', + (s, ws) => s.lookupTransclusionForPublicSpace('handbook', refs, ws), + ], + ])( + '%s 404s before any lookup even with the workspace opted in', + async (_name, call) => { + const { service, spaceRepo, publicSpaceRepo, pageRepo } = + makeService(flagOff); + await expect( + call(service, makeWorkspace(optInSettings)), + ).rejects.toBeInstanceOf(NotFoundException); + expect(spaceRepo.findBySlug).not.toHaveBeenCalled(); + expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled(); + expect(pageRepo.findById).not.toHaveBeenCalled(); + expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled(); + }, + ); + + it('directory 404s before any lookup even with the double opt-in on', async () => { + const findEnabledWithSpaceByWorkspaceId = jest.fn().mockResolvedValue([]); + const { service } = makeService({ + ...flagOff, + publicSpaceRepo: { findEnabledWithSpaceByWorkspaceId }, + }); + await expect( + service.getPublicSpaceDirectory( + makeWorkspace({ publicSpaces: { enabled: true, directory: true } }), + ), + ).rejects.toBeInstanceOf(NotFoundException); + expect(findEnabledWithSpaceByWorkspaceId).not.toHaveBeenCalled(); + }); + + it.each([true, false])( + 'publish rejects every write (enabled=%s) without touching the row', + async (enabled) => { + const { service, publicSpaceRepo } = makeService(flagOff); + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(publicSpaceRepo.findBySpaceId).not.toHaveBeenCalled(); + expect(publicSpaceRepo.upsert).not.toHaveBeenCalled(); + }, + ); + }); + + describe('publish appearance', () => { + it('merges appearance into existing settings preserving unknown keys', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: { + future: 1, + appearance: { primaryColorLight: '#111111' }, + }, + }), + }, + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorDark: '#222222' }, + }); + + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { + future: 1, + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }, + }, + }), + ); + }); + + it('deletes an appearance key when its value is null', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: { + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }, + }, + }), + }, + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorLight: null }, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({ + appearance: { primaryColorDark: '#222222' }, + }); + }); + + it('passes settings undefined when appearance is omitted', async () => { + const { service, publicSpaceRepo } = makeService(); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined(); + }); + + it('rejects appearance when the workspace lacks the license feature', async () => { + const { service, publicSpaceRepo } = makeService({ + licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) }, + }); + + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + appearance: { primaryColorLight: '#111111' }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + + expect(publicSpaceRepo.upsert).not.toHaveBeenCalled(); + }); + }); + + describe('public appearance exposure', () => { + const storedSettings = { + future: 1, + appearance: { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + internal: 'secret', + }, + }; + const publicAppearance = { + primaryColorLight: '#111111', + primaryColorDark: '#222222', + }; + const publicSpaceRepoWithSettings = { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: storedSettings, + }), + }; + + it('whitelists appearance on getPublicSpaceInfo', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on getPublicSpaceTree', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicSpaceTree( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on the served page response', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + }); + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('whitelists appearance on the empty-space page response', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + expect(result.page).toBeNull(); + expect(result.appearance).toEqual(publicAppearance); + expect(result).not.toHaveProperty('settings'); + }); + + it('strips appearance when the workspace lacks the license feature', async () => { + const { service } = makeService({ + publicSpaceRepo: publicSpaceRepoWithSettings, + licenseCheckService: { resolveFeatures: jest.fn().mockReturnValue([]) }, + }); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance).toBeUndefined(); + }); + + it('omits appearance when none is stored', async () => { + const { service } = makeService(); + const result = await service.getPublicSpaceInfo( + 'handbook', + makeWorkspace(optInSettings), + ); + expect(result.appearance ?? {}).toEqual({}); + }); + }); + + describe('byline', () => { + function makeRepoWithByline(byline: any) { + return { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + searchIndexing: false, + settings: byline === null ? null : { byline }, + }), + }; + } + + it('defaults to a hidden author and a visible updated date', async () => { + const { service, pageRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline(null), + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.byline).toEqual({ author: false, updatedAt: true }); + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', { + includeContent: true, + includeCreator: false, + }); + }); + + it('requests the creator only when the author byline is enabled', async () => { + const { service, pageRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true }), + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.byline.author).toBe(true); + expect(pageRepo.findById).toHaveBeenCalledWith('abc123XYZ0', { + includeContent: true, + includeCreator: true, + }); + }); + + it('strips a creator from the page when the author byline is off', async () => { + const { service } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: false }), + pageRepo: { + findById: jest.fn().mockResolvedValue({ + id: 'page1', + slugId: 'abc123XYZ0', + spaceId: SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + creatorId: 'u1', + creator: { id: 'u1', name: 'Jane', avatarUrl: null }, + }), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + 'abc123XYZ0', + makeWorkspace(optInSettings), + ); + + expect(result.page.creatorId).toBe('u1'); + expect((result.page as any).creator).toBeUndefined(); + }); + + it('returns the byline for an empty published space', async () => { + const { service } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: false }), + pageRepo: { + getFirstUnrestrictedRootPage: jest.fn().mockResolvedValue(undefined), + }, + }); + + const result = await service.getPublicPage( + 'handbook', + undefined, + makeWorkspace(optInSettings), + ); + + expect(result.page).toBeNull(); + expect(result.byline).toEqual({ author: true, updatedAt: false }); + }); + + it('merges a single byline field into the stored byline', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true, updatedAt: true }), + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + bylineUpdatedAt: false, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toEqual({ + byline: { author: true, updatedAt: false }, + }); + }); + + it('leaves stored settings untouched when no byline field is sent', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: makeRepoWithByline({ author: true }), + }); + + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert.mock.calls[0][0].settings).toBeUndefined(); + }); + }); + + describe('publish', () => { + it('rejects enabling when the workspace opt-in is off', async () => { + const { service } = makeService(); + await expect( + service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({}), + authUserId: 'u1', + enabled: true, + }), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows enabling when public page sharing is disabled', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true }, + sharing: { disabled: true }, + }), + authUserId: 'u1', + enabled: true, + }); + + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); + + it('allows disabling even when the opt-in is off', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({}), + authUserId: 'u1', + enabled: false, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it('defaults every option on except the author byline on first publish', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true, directory: true }, + }), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + searchIndexing: true, + settings: { directory: true }, + }), + ); + }); + + it('skips the directory default when the workspace directory is off', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: true, + settings: undefined, + }), + ); + }); + + it('keeps explicitly provided flags on first publish', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue(undefined), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace({ + publicSpaces: { enabled: true, directory: true }, + }), + authUserId: 'u1', + enabled: true, + searchIndexing: false, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: false, + settings: { directory: true }, + }), + ); + }); + + it('republish preserves prior customization instead of re-applying defaults', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: false, + searchIndexing: false, + settings: { directory: false }, + }), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + searchIndexing: undefined, + settings: undefined, + }), + ); + }); + }); + + describe('directory', () => { + const directorySettings = { + publicSpaces: { enabled: true, directory: true }, + }; + + function makeDirectoryRows() { + return [ + { + settings: { directory: true }, + searchIndexing: false, + name: 'Handbook', + slug: 'handbook', + description: 'All about us', + logo: null, + }, + { + settings: {}, + searchIndexing: true, + name: 'Hidden', + slug: 'hidden', + description: null, + logo: null, + }, + ]; + } + + it('404s when the workspace opt-in is off', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpaceDirectory( + makeWorkspace({ publicSpaces: { enabled: false, directory: true } }), + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the directory flag is off', async () => { + const { service } = makeService(); + await expect( + service.getPublicSpaceDirectory(makeWorkspace(optInSettings)), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('lists only spaces opted into the directory with whitelisted fields', async () => { + const { service } = makeService({ + publicSpaceRepo: { + findEnabledWithSpaceByWorkspaceId: jest + .fn() + .mockResolvedValue(makeDirectoryRows()), + }, + }); + const result = await service.getPublicSpaceDirectory( + makeWorkspace(directorySettings), + ); + expect(result.spaces).toEqual([ + { + name: 'Handbook', + slug: 'handbook', + description: 'All about us', + logo: null, + }, + ]); + }); + + it('publish stores the directory flag while preserving other settings', async () => { + const { service, publicSpaceRepo } = makeService({ + publicSpaceRepo: { + findBySpaceId: jest.fn().mockResolvedValue({ + id: 'ps1', + spaceId: SPACE_ID, + enabled: true, + settings: { appearance: { primaryColorLight: '#111111' } }, + }), + }, + }); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + directory: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + settings: { + appearance: { primaryColorLight: '#111111' }, + directory: true, + }, + }), + ); + }); + + it('publish leaves settings untouched when directory is omitted', async () => { + const { service, publicSpaceRepo } = makeService(); + await service.publish({ + space: { id: SPACE_ID, workspaceId: WORKSPACE_ID } as any, + workspace: makeWorkspace(optInSettings), + authUserId: 'u1', + enabled: true, + }); + expect(publicSpaceRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ settings: undefined }), + ); + }); + }); + + describe('cross-space link resolution', () => { + const OTHER_SPACE_ID = '018f0000-0000-7000-8000-000000000004'; + + const crossSpacePage = { + id: 'page2', + slugId: 'crossSlug001', + spaceId: OTHER_SPACE_ID, + workspaceId: WORKSPACE_ID, + deletedAt: null, + content: null, + }; + + const otherSpace = { + id: OTHER_SPACE_ID, + slug: 'engineering', + name: 'Engineering', + workspaceId: WORKSPACE_ID, + deletedAt: null, + }; + + it('resolves a contentless probe into another published space', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + }); + const result = await service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ); + expect(result.space.slug).toBe('engineering'); + expect(result.page.id).toBe('page2'); + }); + + it('404s a cross-space target when content is requested', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the target space is not published', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + publicSpaceRepo: { + findBySpaceId: jest + .fn() + .mockImplementation(async (spaceId: string) => + spaceId === SPACE_ID + ? { id: 'ps1', spaceId: SPACE_ID, enabled: true } + : { id: 'ps2', spaceId, enabled: false }, + ), + }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the target space is outside the workspace', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(undefined) }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('404s when the cross-space target has a restricted ancestor', async () => { + const { service } = makeService({ + pageRepo: { findById: jest.fn().mockResolvedValue(crossSpacePage) }, + spaceRepo: { findById: jest.fn().mockResolvedValue(otherSpace) }, + pagePermissionRepo: { + hasRestrictedAncestor: jest.fn().mockResolvedValue(true), + }, + }); + await expect( + service.getPublicPage( + 'handbook', + 'crossSlug001', + makeWorkspace(optInSettings), + { includeContent: false }, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/apps/server/src/core/public-space/public-space.service.ts b/apps/server/src/core/public-space/public-space.service.ts new file mode 100644 index 000000000..d524ddf2a --- /dev/null +++ b/apps/server/src/core/public-space/public-space.service.ts @@ -0,0 +1,397 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; +import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; +import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; +import { ShareService } from '../share/share.service'; +import { TransclusionService } from '../page/transclusion/transclusion.service'; +import { TransclusionLookup } from '../page/transclusion/transclusion.types'; +import { + Page, + PublicSpace, + Space, + Workspace, +} from '@docmost/db/types/entity.types'; +import { PublicSpaceAppearanceDto } from './dto/public-space.dto'; +import { LicenseCheckService } from '../../integrations/environment/license-check.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { Feature, FeatureKey } from '../../common/features'; + +@Injectable() +export class PublicSpaceService { + constructor( + private readonly publicSpaceRepo: PublicSpaceRepo, + private readonly spaceRepo: SpaceRepo, + private readonly pageRepo: PageRepo, + private readonly pagePermissionRepo: PagePermissionRepo, + private readonly shareService: ShareService, + private readonly transclusionService: TransclusionService, + private readonly licenseCheckService: LicenseCheckService, + private readonly environmentService: EnvironmentService, + ) {} + + hasFeature(workspace: Workspace, feature: FeatureKey): boolean { + return this.licenseCheckService + .resolveFeatures(workspace.licenseKey, workspace.plan) + .includes(feature); + } + + isPublicSpacesAllowed(workspace: Workspace): boolean { + const settings = workspace.settings as any; + return ( + this.environmentService.isBetaPublicSpaces() && + settings?.publicSpaces?.enabled === true + ); + } + + private isDirectoryEnabled(workspace: Workspace): boolean { + return (workspace.settings as any)?.publicSpaces?.directory === true; + } + + async getPublicSpace(spaceSlug: string, workspace: Workspace) { + if (!this.isPublicSpacesAllowed(workspace)) { + throw new NotFoundException('Space not found'); + } + + const space = await this.spaceRepo.findBySlug(spaceSlug, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Space not found'); + } + + const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id); + if (!publicSpace?.enabled) { + throw new NotFoundException('Space not found'); + } + + return { space, publicSpace }; + } + + async getPublicSpaceInfo(spaceSlug: string, workspace: Workspace) { + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + return { + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + }; + } + + async getPublicSpaceTree(spaceSlug: string, workspace: Workspace) { + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + const pageTree = await this.pageRepo.getSpacePagesExcludingRestricted( + space.id, + ); + return { + space: this.toPublicSpaceFields(space), + pageTree, + appearance: this.toPublicAppearance(publicSpace, workspace), + }; + } + + async getPublicPage( + spaceSlug: string, + pageSlugId: string | undefined, + workspace: Workspace, + opts?: { includeContent?: boolean }, + ) { + const includeContent = opts?.includeContent !== false; + + const { space, publicSpace } = await this.getPublicSpace( + spaceSlug, + workspace, + ); + const byline = this.getBylineSettings(publicSpace); + + let pageId = pageSlugId; + if (!pageId) { + const firstRoot = await this.pageRepo.getFirstUnrestrictedRootPage( + space.id, + ); + if (!firstRoot) { + return { + page: null, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline, + }; + } + pageId = firstRoot.id; + } + + const page = includeContent + ? await this.pageRepo.findById(pageId, { + includeContent: true, + includeCreator: byline.author, + }) + : await this.pageRepo.findById(pageId); + if (!page || page.deletedAt) { + throw new NotFoundException('Page not found'); + } + + // cross-space targets resolve only as contentless link probes, and only + // into published spaces; content stays canonical under its own space URL + if (page.spaceId !== space.id) { + if (includeContent) { + throw new NotFoundException('Page not found'); + } + return this.resolveCrossSpacePublicPage(page, workspace); + } + + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); + if (isRestricted) { + throw new NotFoundException('Page not found'); + } + + // never ship creator details the space admin chose to hide + if (!byline.author && 'creator' in page) { + delete (page as any).creator; + } + + if (includeContent) { + page.content = await this.shareService.updatePublicAttachments(page); + } + + return { + page, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline, + }; + } + + /** Uniform 404 unless the target page's own space is published, not deleted, in this workspace, and unrestricted. */ + private async resolveCrossSpacePublicPage(page: Page, workspace: Workspace) { + const space = await this.spaceRepo.findById(page.spaceId, workspace.id); + if (!space || space.deletedAt) { + throw new NotFoundException('Page not found'); + } + + const publicSpace = await this.publicSpaceRepo.findBySpaceId(space.id); + if (!publicSpace?.enabled) { + throw new NotFoundException('Page not found'); + } + + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); + if (isRestricted) { + throw new NotFoundException('Page not found'); + } + + return { + page, + space: this.toPublicSpaceFields(space), + searchIndexing: publicSpace.searchIndexing, + appearance: this.toPublicAppearance(publicSpace, workspace), + byline: this.getBylineSettings(publicSpace), + }; + } + + /** Share-style transclusion resolution scoped to one public space; viewer permissions are never consulted. */ + async lookupTransclusionForPublicSpace( + spaceSlug: string, + references: Array<{ sourcePageId: string; transclusionId: string }>, + workspace: Workspace, + ): Promise<{ items: TransclusionLookup[] }> { + const { space } = await this.getPublicSpace(spaceSlug, workspace); + + const candidatePageIds = Array.from( + new Set(references.map((r) => r.sourcePageId)), + ); + + const accessibleResults = await Promise.all( + candidatePageIds.map(async (pageId) => { + const page = await this.pageRepo.findById(pageId); + if (!page || page.deletedAt || page.spaceId !== space.id) return null; + const restricted = + await this.pagePermissionRepo.hasRestrictedAncestor(page.id); + if (restricted) return null; + return page.id; + }), + ); + const accessibleSet = new Set( + accessibleResults.filter((id): id is string => id !== null), + ); + + const { items } = await this.transclusionService.lookupWithAccessSet( + references, + accessibleSet, + workspace.id, + ); + + return { + items: await this.shareService.sanitizeTransclusionItemsForPublic( + items, + workspace.id, + ), + }; + } + + async publish(opts: { + space: Space; + workspace: Workspace; + authUserId: string; + enabled: boolean; + searchIndexing?: boolean; + appearance?: PublicSpaceAppearanceDto; + bylineAuthor?: boolean; + bylineUpdatedAt?: boolean; + directory?: boolean; + }) { + const { + space, + workspace, + authUserId, + enabled, + appearance, + bylineAuthor, + bylineUpdatedAt, + } = opts; + let { searchIndexing, directory } = opts; + + if (!this.environmentService.isBetaPublicSpaces()) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + + if (enabled && !this.isPublicSpacesAllowed(workspace)) { + throw new ForbiddenException( + 'Public spaces are not enabled for this workspace', + ); + } + + if (appearance && !this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) { + throw new ForbiddenException( + 'Public docs appearance requires a paid license', + ); + } + + const prev = await this.publicSpaceRepo.findBySpaceId(space.id); + + // first publish defaults every option on except the author byline; republish keeps prior customization + if (enabled && !prev) { + searchIndexing ??= true; + if (this.isDirectoryEnabled(workspace)) { + directory ??= true; + } + } + + const hasByline = + typeof bylineAuthor !== 'undefined' || + typeof bylineUpdatedAt !== 'undefined'; + + let settings: Record | undefined; + if (appearance || hasByline || typeof directory !== 'undefined') { + const prevSettings = (prev?.settings as Record) ?? {}; + settings = { ...prevSettings }; + + if (typeof directory !== 'undefined') { + settings.directory = directory; + } + + if (appearance) { + const nextAppearance: Record = { + ...(prevSettings.appearance ?? {}), + }; + for (const key of ['primaryColorLight', 'primaryColorDark'] as const) { + const value = appearance[key]; + if (value === null) delete nextAppearance[key]; + else if (typeof value !== 'undefined') nextAppearance[key] = value; + } + settings.appearance = nextAppearance; + } + + if (hasByline) { + const prevByline = this.getBylineSettings(prev); + settings.byline = { + author: bylineAuthor ?? prevByline.author, + updatedAt: bylineUpdatedAt ?? prevByline.updatedAt, + }; + } + } + + return this.publicSpaceRepo.upsert({ + spaceId: space.id, + workspaceId: space.workspaceId, + enabled, + searchIndexing, + creatorId: authUserId, + settings, + }); + } + + /** The /docs hub: listed public spaces only, behind the workspace double opt-in. */ + async getPublicSpaceDirectory(workspace: Workspace) { + if ( + !this.isPublicSpacesAllowed(workspace) || + !this.isDirectoryEnabled(workspace) + ) { + throw new NotFoundException('Not found'); + } + + const rows = await this.publicSpaceRepo.findEnabledWithSpaceByWorkspaceId( + workspace.id, + ); + const listed = rows.filter( + (row) => (row.settings as any)?.directory === true, + ); + + return { + spaces: listed.map((row) => ({ + name: row.name, + slug: row.slug, + description: row.description, + logo: row.logo, + })), + }; + } + + private toPublicSpaceFields(space: Space) { + return { + id: space.id, + name: space.name, + slug: space.slug, + description: space.description, + logo: space.logo, + }; + } + + private getBylineSettings(publicSpace: PublicSpace) { + const byline = (publicSpace?.settings as any)?.byline; + return { + author: byline?.author === true, + updatedAt: byline?.updatedAt !== false, + }; + } + + private toPublicAppearance(publicSpace: PublicSpace, workspace: Workspace) { + if (!this.hasFeature(workspace, Feature.PUBLIC_SPACE_APPEARANCE)) { + return undefined; + } + const appearance = (publicSpace?.settings as any)?.appearance; + if (!appearance) return undefined; + const result: { primaryColorLight?: string; primaryColorDark?: string } = + {}; + if (typeof appearance.primaryColorLight === 'string') { + result.primaryColorLight = appearance.primaryColorLight; + } + if (typeof appearance.primaryColorDark === 'string') { + result.primaryColorDark = appearance.primaryColorDark; + } + return Object.keys(result).length > 0 ? result : undefined; + } +} diff --git a/apps/server/src/core/search/dto/search.dto.ts b/apps/server/src/core/search/dto/search.dto.ts index 8be6d338d..b9d09d17d 100644 --- a/apps/server/src/core/search/dto/search.dto.ts +++ b/apps/server/src/core/search/dto/search.dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsNotEmpty, IsNumber, @@ -8,9 +9,9 @@ import { } from 'class-validator'; export class SearchDTO { - @IsNotEmpty() + @IsOptional() @IsString() - query: string; + query?: string; @IsOptional() @IsUUID() @@ -24,6 +25,15 @@ export class SearchDTO { @IsUUID() creatorId?: string; + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + labelIds?: string[]; + + @IsOptional() + @IsBoolean() + titleOnly?: boolean; + @IsOptional() @IsNumber() limit?: number; @@ -43,6 +53,12 @@ export class SearchShareDTO extends SearchDTO { spaceId: string; } +export class SearchPublicSpaceDTO extends SearchDTO { + @IsNotEmpty() + @IsString() + spaceSlug: string; +} + export class SearchSuggestionDTO { @IsString() query: string; diff --git a/apps/server/src/core/search/search.controller.spec.ts b/apps/server/src/core/search/search.controller.spec.ts index 6d6bad58e..b04994764 100644 --- a/apps/server/src/core/search/search.controller.spec.ts +++ b/apps/server/src/core/search/search.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; import { SearchController } from './search.controller'; describe('SearchController', () => { @@ -16,3 +17,77 @@ describe('SearchController', () => { expect(controller).toBeDefined(); }); }); + +describe('SearchController public-space-search gate', () => { + function makeController(overrides: any = {}) { + const searchService = { searchPage: jest.fn().mockResolvedValue([]) }; + const environmentService = { + getSearchDriver: jest.fn().mockReturnValue('postgres'), + }; + const publicSpaceService = { + getPublicSpace: jest + .fn() + .mockRejectedValue(new NotFoundException('Space not found')), + ...overrides.publicSpaceService, + }; + const pageRepo = { + getSpacePagesExcludingRestricted: jest + .fn() + .mockResolvedValue([{ id: 'p1' }, { id: 'p2' }]), + }; + const controller = new SearchController( + searchService as any, + {} as any, + environmentService as any, + publicSpaceService as any, + pageRepo as any, + {} as any, + ); + return { controller, searchService, publicSpaceService, pageRepo }; + } + + const workspace = { id: 'ws1' } as any; + + it('does not read pages or search when the public space gate rejects', async () => { + const { controller, searchService, pageRepo } = makeController(); + await expect( + controller.searchPublicSpace( + { query: 'roadmap', spaceSlug: 'handbook' } as any, + workspace, + ), + ).rejects.toBeInstanceOf(NotFoundException); + expect(pageRepo.getSpacePagesExcludingRestricted).not.toHaveBeenCalled(); + expect(searchService.searchPage).not.toHaveBeenCalled(); + }); + + it('searches only the unrestricted pages of the gated space, ignoring client filters', async () => { + const { controller, searchService, publicSpaceService, pageRepo } = + makeController({ + publicSpaceService: { + getPublicSpace: jest + .fn() + .mockResolvedValue({ space: { id: 's1' }, publicSpace: {} }), + }, + }); + await controller.searchPublicSpace( + { + query: 'roadmap', + spaceSlug: 'handbook', + spaceId: 'attacker-space', + shareId: 'share1', + creatorId: 'u1', + labelIds: ['l1'], + } as any, + workspace, + ); + expect(publicSpaceService.getPublicSpace).toHaveBeenCalledWith( + 'handbook', + workspace, + ); + expect(pageRepo.getSpacePagesExcludingRestricted).toHaveBeenCalledWith('s1'); + expect(searchService.searchPage).toHaveBeenCalledWith( + { query: 'roadmap', spaceSlug: 'handbook' }, + { workspaceId: 'ws1', publicPageIds: ['p1', 'p2'] }, + ); + }); +}); diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts index c968c3443..469fa9c7e 100644 --- a/apps/server/src/core/search/search.controller.ts +++ b/apps/server/src/core/search/search.controller.ts @@ -12,11 +12,13 @@ import { import { SearchService } from './search.service'; import { SearchDTO, + SearchPublicSpaceDTO, SearchShareDTO, SearchSuggestionDTO, } 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 { @@ -27,6 +29,8 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { Public } from 'src/common/decorators/public.decorator'; import { EnvironmentService } from '../../integrations/environment/environment.service'; import { ModuleRef } from '@nestjs/core'; +import { PublicSpaceService } from '../public-space/public-space.service'; +import { PageRepo } from '@docmost/db/repos/page/page.repo'; @UseGuards(JwtAuthGuard) @Controller('search') @@ -37,11 +41,14 @@ export class SearchController { private readonly searchService: SearchService, private readonly spaceAbility: SpaceAbilityFactory, private readonly environmentService: EnvironmentService, + private readonly publicSpaceService: PublicSpaceService, + private readonly pageRepo: PageRepo, private moduleRef: ModuleRef, ) {} @HttpCode(HttpStatus.OK) @Post() + @OAuthScope('read') async pageSearch( @Body() searchDto: SearchDTO, @AuthUser() user: User, @@ -75,6 +82,7 @@ export class SearchController { @HttpCode(HttpStatus.OK) @Post('suggest') + @OAuthScope('read') async searchSuggestions( @Body() dto: SearchSuggestionDTO, @AuthUser() user: User, @@ -106,14 +114,51 @@ export class SearchController { }); } + @Public() + @HttpCode(HttpStatus.OK) + @Post('public-space-search') + async searchPublicSpace( + @Body() searchDto: SearchPublicSpaceDTO, + @AuthWorkspace() workspace: Workspace, + ) { + delete searchDto.spaceId; + delete searchDto.shareId; + // Member-facing filters stay internal: creator identities and label + // taxonomy must not become a grouping oracle on the anonymous surface. + delete searchDto.creatorId; + delete searchDto.labelIds; + + const { space } = await this.publicSpaceService.getPublicSpace( + searchDto.spaceSlug, + workspace, + ); + const pages = await this.pageRepo.getSpacePagesExcludingRestricted( + space.id, + ); + const publicPageIds = pages.map((page) => page.id); + + if (this.environmentService.getSearchDriver() === 'typesense') { + return this.searchTypesense(searchDto, { + workspaceId: workspace.id, + publicPageIds, + }); + } + + return this.searchService.searchPage(searchDto, { + workspaceId: workspace.id, + publicPageIds, + }); + } + async searchTypesense( searchParams: SearchDTO, opts: { userId?: string; workspaceId: string; + publicPageIds?: string[]; }, ) { - const { userId, workspaceId } = opts; + const { userId, workspaceId, publicPageIds } = opts; let TypesenseModule: any; try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -129,6 +174,7 @@ export class SearchController { return PageSearchService.searchPage(searchParams, { userId: userId, workspaceId, + publicPageIds, }); } catch (err) { this.logger.debug( diff --git a/apps/server/src/core/search/search.module.ts b/apps/server/src/core/search/search.module.ts index fa74573df..4bd944e27 100644 --- a/apps/server/src/core/search/search.module.ts +++ b/apps/server/src/core/search/search.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; +import { PublicSpaceModule } from '../public-space/public-space.module'; @Module({ + imports: [PublicSpaceModule], controllers: [SearchController], providers: [SearchService], exports: [SearchService], diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 39c4dbe01..f8e8e5517 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -27,14 +27,39 @@ export class SearchService { opts: { userId?: string; workspaceId: string; + publicPageIds?: string[]; }, ): Promise<{ items: SearchResponseDto[] }> { - const { query } = searchParams; + const query = searchParams.query?.trim() ?? ''; + const labelIds = [...new Set(searchParams.labelIds ?? [])]; + // selected filters (labels, creator) are browsable without a query + const browseByFilters = + query.length < 1 && + (labelIds.length > 0 || Boolean(searchParams.creatorId)); - if (query.length < 1) { + if (query.length < 1 && !browseByFilters) { return { items: [] }; } - const searchQuery = tsquery(query.trim() + '*'); + const searchQuery = tsquery(query + '*'); + const titleOnly = searchParams.titleOnly === true; + const titleQuery = query; + // escape LIKE wildcards; ranking keeps the raw query + const titleLikeQuery = query.replace(/[\\%_]/g, '\\$&'); + + const rankColumn = browseByFilters + ? sql`0`.as('rank') + : titleOnly + ? sql`word_similarity(lower(${titleQuery}), lower(pages.title))`.as( + 'rank', + ) + : sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( + 'rank', + ); + const highlightColumn = browseByFilters || titleOnly + ? sql`''`.as('highlight') + : sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( + 'highlight', + ); let queryResults = this.db .selectFrom('pages') @@ -47,32 +72,49 @@ export class SearchService { 'creatorId', 'createdAt', 'updatedAt', - sql`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as( - 'rank', - ), - sql`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as( - 'highlight', - ), + rankColumn, + highlightColumn, ]) - .where( - 'tsv', - '@@', - sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + .$if(!browseByFilters && !titleOnly, (qb) => + qb.where( + 'tsv', + '@@', + sql`to_tsquery('english', f_unaccent(${searchQuery}))`, + ), + ) + .$if(!browseByFilters && titleOnly, (qb) => + qb.where((eb) => + eb( + sql`lower(pages.title)`, + 'like', + sql`lower(${`%${titleLikeQuery}%`})`, + ), + ), ) .$if(Boolean(searchParams.creatorId), (qb) => qb.where('creatorId', '=', searchParams.creatorId), ) + .$if(labelIds?.length > 0, (qb) => + qb.where( + 'id', + 'in', + this.db + .selectFrom('pageLabels') + .select('pageId') + .where('labelId', 'in', labelIds), + ), + ) .where('deletedAt', 'is', null) - .orderBy('rank', 'desc') + .$if(browseByFilters, (qb) => qb.orderBy('updatedAt', 'desc')) + .$if(!browseByFilters, (qb) => qb.orderBy('rank', 'desc')) .limit(searchParams.limit || 25) .offset(searchParams.offset || 0); - if (!searchParams.shareId) { + if (!searchParams.shareId && !opts.publicPageIds) { queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb)); } - if (searchParams.spaceId) { - // search by spaceId + if (searchParams.spaceId && opts.userId) { queryResults = queryResults.where('spaceId', '=', searchParams.spaceId); } else if (opts.userId && !searchParams.spaceId) { // only search spaces the user is a member of @@ -83,6 +125,15 @@ export class SearchService { this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId), ) .where('workspaceId', '=', opts.workspaceId); + } else if (opts.publicPageIds && !opts.userId) { + // Public space search: the allowed id set is computed from live DB + // state by the controller on every request. + if (opts.publicPageIds.length === 0) { + return { items: [] }; + } + queryResults = queryResults + .where('id', 'in', opts.publicPageIds) + .where('workspaceId', '=', opts.workspaceId); } else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) { // search in shares const shareId = searchParams.shareId; diff --git a/apps/server/src/core/share/share-seo.controller.ts b/apps/server/src/core/share/share-seo.controller.ts index 51967ada5..d2cfdd975 100644 --- a/apps/server/src/core/share/share-seo.controller.ts +++ b/apps/server/src/core/share/share-seo.controller.ts @@ -85,8 +85,8 @@ export class ShareSeoController { const html = fs.readFileSync(indexFilePath, 'utf8'); const transformedHtml = html - .replace(/[\s\S]*?<\/title>/i, `<title>${metaTitle}`) - .replace(metaTagVar, metaTags); + .replace(/[\s\S]*?<\/title>/i, () => `<title>${metaTitle}`) + .replace(metaTagVar, () => metaTags); res.type('text/html').send(transformedHtml); } diff --git a/apps/server/src/core/share/share.service.ts b/apps/server/src/core/share/share.service.ts index 03ee31555..68acf48e8 100644 --- a/apps/server/src/core/share/share.service.ts +++ b/apps/server/src/core/share/share.service.ts @@ -46,8 +46,9 @@ export class ShareService { throw new NotFoundException('Share not found'); } - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + share.pageId, + ); if (isRestricted) { throw new NotFoundException('Share not found'); } @@ -110,6 +111,9 @@ export class ShareService { } async getSharedPage(dto: ShareInfoDto, workspaceId: string) { + //TODO: we should resolve the page from the share id + if (!dto.pageId) throw new NotFoundException('Shared page not found'); + const share = await this.getShareForPage(dto.pageId, workspaceId); if (!share) { @@ -126,8 +130,9 @@ export class ShareService { } // Block access to restricted pages - const isRestricted = - await this.pagePermissionRepo.hasRestrictedAncestor(page.id); + const isRestricted = await this.pagePermissionRepo.hasRestrictedAncestor( + page.id, + ); if (isRestricted) { throw new NotFoundException('Shared page not found'); } @@ -360,35 +365,9 @@ export class ShareService { workspaceId, ); - // Sanitize each item's content for public delivery - // generate per-attachment tokens scoped to the source page - // and strip comment marks. - const tokenized = await Promise.all( - items.map(async (item) => { - if ('status' in item) return item; - const doc = await this.prepareContentForShare( - item.content, - item.sourcePageId, - workspaceId, - ); - return { ...item, content: doc?.toJSON() ?? item.content }; - }), - ); - - // Collapse `not_found` to `no_access` for share viewers so the response - // can't be used to tell "page is shared but transclusion id doesn't - // match" from "page isn't shared at all". - const sanitized = tokenized.map((item) => - 'status' in item && item.status === 'not_found' - ? { - sourcePageId: item.sourcePageId, - transclusionId: item.transclusionId, - status: 'no_access' as const, - } - : item, - ); - - return { items: sanitized }; + return { + items: await this.sanitizeTransclusionItemsForPublic(items, workspaceId), + }; } async isSharingAllowed( @@ -425,6 +404,38 @@ export class ShareService { return doc?.toJSON() ?? page.content; } + /** + * Sanitization tail shared by every public transclusion surface: tokenize + * each content item against its source page, then hide lookup misses. + */ + async sanitizeTransclusionItemsForPublic( + items: TransclusionLookup[], + workspaceId: string, + ): Promise { + const tokenized = await Promise.all( + items.map(async (item) => { + if ('status' in item) return item; + const doc = await this.prepareContentForShare( + item.content, + item.sourcePageId, + workspaceId, + ); + return { ...item, content: doc?.toJSON() ?? item.content }; + }), + ); + + // Collapse not_found to no_access so hidden sources are indistinguishable from missing ids. + return tokenized.map((item) => + 'status' in item && item.status === 'not_found' + ? { + sourcePageId: item.sourcePageId, + transclusionId: item.transclusionId, + status: 'no_access' as const, + } + : item, + ); + } + /** * Prepare a ProseMirror JSON doc for delivery to a public share viewer. * Performs the two transforms required by the share threat model: diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts index 74dfebf30..7ba8509aa 100644 --- a/apps/server/src/core/space/space.controller.ts +++ b/apps/server/src/core/space/space.controller.ts @@ -13,6 +13,7 @@ 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'; @@ -48,6 +49,7 @@ export class SpaceController { @HttpCode(HttpStatus.OK) @Post('/') + @OAuthScope('read') async getWorkspaceSpaces( @Body() pagination: PaginationOptions, @@ -92,6 +94,7 @@ export class SpaceController { @HttpCode(HttpStatus.OK) @Post('info') + @OAuthScope('read') async getSpaceInfo( @Body() spaceIdDto: SpaceIdDto, @AuthUser() user: User, @@ -129,6 +132,7 @@ export class SpaceController { @HttpCode(HttpStatus.OK) @Post('create') + @OAuthScope('write') createSpace( @Body() createSpaceDto: CreateSpaceDto, @AuthUser() user: User, @@ -145,6 +149,7 @@ export class SpaceController { @HttpCode(HttpStatus.OK) @Post('update') + @OAuthScope('write') async updateSpace( @Body() updateSpaceDto: UpdateSpaceDto, @AuthUser() user: User, diff --git a/apps/server/src/core/user/user.controller.ts b/apps/server/src/core/user/user.controller.ts index 8d51ce6b7..3184f2277 100644 --- a/apps/server/src/core/user/user.controller.ts +++ b/apps/server/src/core/user/user.controller.ts @@ -10,6 +10,7 @@ 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'; @@ -24,6 +25,7 @@ export class UserController { @HttpCode(HttpStatus.OK) @Post('me') + @OAuthScope('read') async getUserInfo( @AuthUser() authUser: User, @AuthWorkspace() workspace: Workspace, diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts index f12499982..dd33ae39b 100644 --- a/apps/server/src/core/workspace/controllers/workspace.controller.ts +++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts @@ -24,6 +24,7 @@ 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 { @@ -58,6 +59,7 @@ export class WorkspaceController { @HttpCode(HttpStatus.OK) @Post('/info') + @OAuthScope('read') async getWorkspace(@AuthWorkspace() workspace: Workspace) { return this.workspaceService.getWorkspaceInfo(workspace.id); } @@ -113,6 +115,7 @@ export class WorkspaceController { @HttpCode(HttpStatus.OK) @Post('members') + @OAuthScope('read') async getWorkspaceMembers( @Body() pagination: PaginationOptions, diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts index 87e969e16..eb5e44d29 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -39,6 +39,14 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsBoolean() disablePublicSharing: boolean; + @IsOptional() + @IsBoolean() + allowPublicSpaces: boolean; + + @IsOptional() + @IsBoolean() + publicSpacesDirectory: boolean; + @IsOptional() @IsBoolean() mcpEnabled: boolean; @@ -68,4 +76,16 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsString() @IsIn(['read', 'edit']) defaultPageEditMode: string; + + @IsOptional() + @IsBoolean() + aiChatReadOnly: boolean; + + @IsOptional() + @IsBoolean() + aiChatWorkspaceKnowledgeOnly: boolean; + + @IsOptional() + @IsBoolean() + enforceMcpOauth: boolean; } diff --git a/apps/server/src/core/workspace/services/workspace.service.spec.ts b/apps/server/src/core/workspace/services/workspace.service.spec.ts deleted file mode 100644 index 0f5443495..000000000 --- a/apps/server/src/core/workspace/services/workspace.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { WorkspaceService } from './workspace.service'; - -describe('WorkspaceService', () => { - let service: WorkspaceService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [WorkspaceService], - }).compile(); - - service = module.get(WorkspaceService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 68dfd271a..c110bb8d1 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -42,6 +42,7 @@ import { import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { AuditEvent, AuditResource } from '../../../common/events/audit-events'; @@ -65,6 +66,7 @@ export class WorkspaceService { private domainService: DomainService, private licenseCheckService: LicenseCheckService, private shareRepo: ShareRepo, + private readonly publicSpaceRepo: PublicSpaceRepo, private watcherRepo: WatcherRepo, private favoriteRepo: FavoriteRepo, @InjectKysely() private readonly db: KyselyDB, @@ -334,7 +336,10 @@ export class WorkspaceService { typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' || typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' || typeof updateWorkspaceDto.isScimEnabled !== 'undefined' || - typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' + typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' || + typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' || + typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' || + typeof updateWorkspaceDto.enforceMcpOauth !== 'undefined' ) { const ws = await this.db .selectFrom('workspaces') @@ -374,6 +379,33 @@ export class WorkspaceService { } } + if ( + typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' || + typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' + ) { + if ( + !this.licenseCheckService.hasFeature( + ws.licenseKey, + Feature.AI_CONTROLS, + ws.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + } + + 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' || @@ -474,6 +506,47 @@ export class WorkspaceService { } } + if ( + !this.environmentService.isBetaPublicSpaces() && + (typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined' || + typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined') + ) { + throw new ForbiddenException( + 'Public spaces are not enabled on this instance', + ); + } + + if (typeof updateWorkspaceDto.allowPublicSpaces !== 'undefined') { + const prev = settingsBefore?.publicSpaces?.enabled ?? false; + if (prev !== updateWorkspaceDto.allowPublicSpaces) { + before.allowPublicSpaces = prev; + after.allowPublicSpaces = updateWorkspaceDto.allowPublicSpaces; + } + await this.workspaceRepo.updatePublicSpacesSettings( + workspaceId, + 'enabled', + updateWorkspaceDto.allowPublicSpaces, + trx, + ); + if (!updateWorkspaceDto.allowPublicSpaces) { + await this.publicSpaceRepo.disableByWorkspaceId(workspaceId, trx); + } + } + + if (typeof updateWorkspaceDto.publicSpacesDirectory !== 'undefined') { + const prev = settingsBefore?.publicSpaces?.directory ?? false; + if (prev !== updateWorkspaceDto.publicSpacesDirectory) { + before.publicSpacesDirectory = prev; + after.publicSpacesDirectory = updateWorkspaceDto.publicSpacesDirectory; + } + await this.workspaceRepo.updatePublicSpacesSettings( + workspaceId, + 'directory', + updateWorkspaceDto.publicSpacesDirectory, + trx, + ); + } + if (typeof updateWorkspaceDto.mcpEnabled !== 'undefined') { const prev = settingsBefore?.ai?.mcp ?? false; if (prev !== updateWorkspaceDto.mcpEnabled) { @@ -516,6 +589,48 @@ export class WorkspaceService { ); } + if (typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined') { + const prev = settingsBefore?.ai?.chatReadOnly ?? false; + if (prev !== updateWorkspaceDto.aiChatReadOnly) { + before.aiChatReadOnly = prev; + after.aiChatReadOnly = updateWorkspaceDto.aiChatReadOnly; + } + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'chatReadOnly', + updateWorkspaceDto.aiChatReadOnly, + trx, + ); + } + + if (typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined') { + const prev = settingsBefore?.ai?.chatWorkspaceKnowledgeOnly ?? false; + if (prev !== updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly) { + before.aiChatWorkspaceKnowledgeOnly = prev; + after.aiChatWorkspaceKnowledgeOnly = updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly; + } + await this.workspaceRepo.updateAiSettings( + workspaceId, + 'chatWorkspaceKnowledgeOnly', + updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly, + trx, + ); + } + + 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) { @@ -548,11 +663,16 @@ export class WorkspaceService { delete updateWorkspaceDto.aiSearch; delete updateWorkspaceDto.generativeAi; delete updateWorkspaceDto.disablePublicSharing; + delete updateWorkspaceDto.allowPublicSpaces; + delete updateWorkspaceDto.publicSpacesDirectory; delete updateWorkspaceDto.mcpEnabled; delete updateWorkspaceDto.allowMemberTemplates; delete updateWorkspaceDto.aiChat; delete updateWorkspaceDto.allowPersonalSpaces; delete updateWorkspaceDto.defaultPageEditMode; + delete updateWorkspaceDto.aiChatReadOnly; + delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly; + delete updateWorkspaceDto.enforceMcpOauth; await this.workspaceRepo.updateWorkspace( updateWorkspaceDto, diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index d5ecb4ac9..89164acbe 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -22,6 +22,7 @@ import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo'; +import { PublicSpaceRepo } from '@docmost/db/repos/public-space/public-space.repo'; import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo'; import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo'; import { LabelRepo } from '@docmost/db/repos/label/label.repo'; @@ -88,6 +89,7 @@ import { normalizePostgresUrl } from '../common/helpers'; UserSessionRepo, BacklinkRepo, ShareRepo, + PublicSpaceRepo, NotificationRepo, WatcherRepo, LabelRepo, @@ -113,6 +115,7 @@ import { normalizePostgresUrl } from '../common/helpers'; UserSessionRepo, BacklinkRepo, ShareRepo, + PublicSpaceRepo, NotificationRepo, WatcherRepo, LabelRepo, diff --git a/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts new file mode 100644 index 000000000..28e3207ea --- /dev/null +++ b/apps/server/src/database/migrations/20260824T211732-page-title-trgm-index.ts @@ -0,0 +1,17 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE INDEX IF NOT EXISTS pages_title_trgm_idx ON pages USING gin (lower(title) gin_trgm_ops)`.execute( + db, + ); + + // separators normalized to spaces so space-typed queries match How_to_export.pdf + await sql`CREATE INDEX IF NOT EXISTS attachments_file_name_trgm_idx ON attachments USING gin (lower(translate(file_name, '_.-', ' ')) gin_trgm_ops)`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS attachments_file_name_trgm_idx`.execute(db); + await sql`DROP INDEX IF EXISTS pages_title_trgm_idx`.execute(db); +} diff --git a/apps/server/src/database/migrations/20260825T022612-oauth.ts b/apps/server/src/database/migrations/20260825T022612-oauth.ts new file mode 100644 index 000000000..0ba5d5937 --- /dev/null +++ b/apps/server/src/database/migrations/20260825T022612-oauth.ts @@ -0,0 +1,88 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + 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(); +} diff --git a/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts new file mode 100644 index 000000000..7b76cacea --- /dev/null +++ b/apps/server/src/database/migrations/20260902T121326-siem-destinations.ts @@ -0,0 +1,66 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('siem_destinations') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.notNull().references('workspaces.id').onDelete('cascade'), + ) + .addColumn('name', 'varchar', (col) => col.notNull()) + .addColumn('type', 'varchar', (col) => col.notNull()) + .addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(true)) + .addColumn('config', 'jsonb', (col) => col.notNull()) + .addColumn('secrets', 'text', (col) => col.notNull()) + .addColumn('cursor_created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('cursor_id', 'uuid', (col) => + col.notNull().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('cursor_snapshot', 'text') + // Fences cursor writes from stale jobs after configuration changes. + .addColumn('version', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('status', 'varchar', (col) => col.notNull().defaultTo('healthy')) + .addColumn('consecutive_failures', 'integer', (col) => + col.notNull().defaultTo(0), + ) + .addColumn('next_attempt_at', 'timestamptz') + .addColumn('last_delivered_at', 'timestamptz') + .addColumn('last_error', 'text') + .addColumn('last_error_at', 'timestamptz') + .addColumn('failing_since', 'timestamptz') + .addColumn('creator_id', 'uuid', (col) => + col.references('users.id').onDelete('set null'), + ) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); + + await db.schema + .createIndex('idx_siem_destinations_workspace_id') + .ifNotExists() + .on('siem_destinations') + .columns(['workspace_id']) + .execute(); + + await sql` + CREATE INDEX IF NOT EXISTS idx_siem_destinations_due + ON siem_destinations (next_attempt_at) + WHERE enabled = true + `.execute(db); + + await db.schema.alterTable('audit').addColumn('user_agent', 'text').execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('audit').dropColumn('user_agent').execute(); + await db.schema.dropTable('siem_destinations').ifExists().execute(); +} diff --git a/apps/server/src/database/migrations/20260904T171920-public-spaces.ts b/apps/server/src/database/migrations/20260904T171920-public-spaces.ts new file mode 100644 index 000000000..2408faadf --- /dev/null +++ b/apps/server/src/database/migrations/20260904T171920-public-spaces.ts @@ -0,0 +1,38 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('public_spaces') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('space_id', 'uuid', (col) => + col.references('spaces.id').onDelete('cascade').notNull().unique(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + .addColumn('enabled', 'boolean', (col) => col.notNull().defaultTo(false)) + .addColumn('search_indexing', 'boolean', (col) => + col.notNull().defaultTo(false), + ) + .addColumn('settings', 'jsonb', (col) => col) + .addColumn('creator_id', 'uuid', (col) => col.references('users.id')) + .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('public_spaces_workspace_id_idx') + .on('public_spaces') + .column('workspace_id') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('public_spaces').execute(); +} diff --git a/apps/server/src/database/repos/favorite/favorite.repo.ts b/apps/server/src/database/repos/favorite/favorite.repo.ts index 7b7aee302..464c49920 100644 --- a/apps/server/src/database/repos/favorite/favorite.repo.ts +++ b/apps/server/src/database/repos/favorite/favorite.repo.ts @@ -8,6 +8,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely'; import { DB } from '@docmost/db/types/db'; import { dbOrTx } from '@docmost/db/utils'; +import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; export const FavoriteType = { PAGE: 'page', @@ -19,7 +20,10 @@ export type FavoriteType = (typeof FavoriteType)[keyof typeof FavoriteType]; @Injectable() export class FavoriteRepo { - constructor(@InjectKysely() private readonly db: KyselyDB) {} + constructor( + @InjectKysely() private readonly db: KyselyDB, + private readonly spaceMemberRepo: SpaceMemberRepo, + ) {} async insert(favorite: InsertableFavorite): Promise { try { @@ -82,6 +86,8 @@ export class FavoriteRepo { .where('favorites.workspaceId', '=', workspaceId) .where('favorites.type', '=', type); + query = this.applyMembershipFilter(query, userId); + if (spaceId) { query = this.applySpaceFilter(query, type, spaceId); } @@ -113,6 +119,8 @@ export class FavoriteRepo { .where('favorites.userId', '=', userId) .where('favorites.workspaceId', '=', workspaceId); + query = this.applyMembershipFilter(query, userId); + if (type) { query = query.where('favorites.type', '=', type); } @@ -155,7 +163,7 @@ export class FavoriteRepo { ): Promise { if (userIds.length === 0) return; - const { trx } = opts; + const { trx } = opts ?? {}; const db = dbOrTx(this.db, trx); const usersWithAccess = db @@ -174,7 +182,25 @@ export class FavoriteRepo { await db .deleteFrom('favorites') .where('userId', 'in', userIds) - .where('spaceId', '=', spaceId) + .where((eb) => + eb.or([ + eb('spaceId', '=', spaceId), + eb.exists( + eb + .selectFrom('pages') + .select(sql`1`.as('one')) + .whereRef('pages.id', '=', 'favorites.pageId') + .where('pages.spaceId', '=', spaceId), + ), + eb.exists( + eb + .selectFrom('templates') + .select(sql`1`.as('one')) + .whereRef('templates.id', '=', 'favorites.templateId') + .where('templates.spaceId', '=', spaceId), + ), + ]), + ) .where('userId', 'not in', usersWithAccess) .execute(); } @@ -194,6 +220,46 @@ export class FavoriteRepo { .execute(); } + private applyMembershipFilter>( + query: Q, + userId: string, + ): Q { + const spaceIds = this.spaceMemberRepo.getUserSpaceIdsQuery(userId); + return query.where((eb: any) => + eb.or([ + eb.and([ + eb('favorites.type', '=', FavoriteType.SPACE), + eb('favorites.spaceId', 'in', spaceIds), + ]), + eb.and([ + eb('favorites.type', '=', FavoriteType.PAGE), + eb.exists( + eb + .selectFrom('pages') + .select(sql`1`.as('one')) + .whereRef('pages.id', '=', 'favorites.pageId') + .where('pages.spaceId', 'in', spaceIds), + ), + ]), + eb.and([ + eb('favorites.type', '=', FavoriteType.TEMPLATE), + eb.exists( + eb + .selectFrom('templates') + .select(sql`1`.as('one')) + .whereRef('templates.id', '=', 'favorites.templateId') + .where((e: any) => + e.or([ + e('templates.spaceId', 'is', null), + e('templates.spaceId', 'in', spaceIds), + ]), + ), + ), + ]), + ]), + ) as Q; + } + private applySpaceFilter>( query: Q, type: FavoriteType | undefined, @@ -239,7 +305,8 @@ export class FavoriteRepo { 'pages.isBase', 'pages.spaceId', ]) - .whereRef('pages.id', '=', 'favorites.pageId'), + .whereRef('pages.id', '=', 'favorites.pageId') + .where(sql.ref('favorites.type'), '=', FavoriteType.PAGE), ).as('page'); } @@ -269,8 +336,8 @@ export class FavoriteRepo { .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) .where(({ or, ref }) => or([ - sql`${ref('spaces.id')} = ${ref('favorites.spaceId')}`, - sql`${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`, + sql`${ref('favorites.type')} = ${FavoriteType.SPACE} and ${ref('spaces.id')} = ${ref('favorites.spaceId')}`, + sql`${ref('favorites.type')} = ${FavoriteType.PAGE} and ${ref('spaces.id')} = (SELECT pages.space_id FROM pages WHERE pages.id = ${ref('favorites.pageId')})`, ]), ), ).as('space'); @@ -287,7 +354,8 @@ export class FavoriteRepo { 'templates.icon', 'templates.spaceId', ]) - .whereRef('templates.id', '=', 'favorites.templateId'), + .whereRef('templates.id', '=', 'favorites.templateId') + .where(sql.ref('favorites.type'), '=', FavoriteType.TEMPLATE), ).as('template'); } } diff --git a/apps/server/src/database/repos/group/group-user.repo.ts b/apps/server/src/database/repos/group/group-user.repo.ts index 08184e2c9..76b023f81 100644 --- a/apps/server/src/database/repos/group/group-user.repo.ts +++ b/apps/server/src/database/repos/group/group-user.repo.ts @@ -60,6 +60,10 @@ export class GroupUserRepo { sql`f_unaccent(users.name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`, + ).or( + sql`users.email`, + 'ilike', + sql`f_unaccent(${'%' + pagination.query + '%'})`, ), ); } diff --git a/apps/server/src/database/repos/page/page.repo.ts b/apps/server/src/database/repos/page/page.repo.ts index 9eb9f3a50..389314236 100644 --- a/apps/server/src/database/repos/page/page.repo.ts +++ b/apps/server/src/database/repos/page/page.repo.ts @@ -605,4 +605,83 @@ export class PageRepo { .execute() ); } + + /** + * All pages of a space excluding restricted subtrees. + * Used by public spaces; a restricted page hides its whole subtree. + */ + async getSpacePagesExcludingRestricted(spaceId: string) { + return this.db + .withRecursive('page_hierarchy', (db) => + db + .selectFrom('pages') + .leftJoin('pageAccess', 'pageAccess.pageId', 'pages.id') + .select([ + 'pages.id', + 'pages.slugId', + 'pages.title', + 'pages.icon', + 'pages.position', + 'pages.parentPageId', + 'pages.spaceId', + 'pages.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .where('pages.spaceId', '=', spaceId) + .where('pages.parentPageId', 'is', null) + .where('pages.deletedAt', 'is', null) + .unionAll((exp) => + exp + .selectFrom('pages as p') + .innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id') + .leftJoin('pageAccess', 'pageAccess.pageId', 'p.id') + .select([ + 'p.id', + 'p.slugId', + 'p.title', + 'p.icon', + 'p.position', + 'p.parentPageId', + 'p.spaceId', + 'p.workspaceId', + sql`page_access.id IS NOT NULL`.as('isRestricted'), + ]) + .where('p.deletedAt', 'is', null) + .where('ph.isRestricted', '=', false), + ), + ) + .selectFrom('page_hierarchy') + .select([ + 'id', + 'slugId', + 'title', + 'icon', + 'position', + 'parentPageId', + 'spaceId', + 'workspaceId', + ]) + .where('isRestricted', '=', false) + .execute(); + } + + async getFirstUnrestrictedRootPage(spaceId: string) { + return this.db + .selectFrom('pages') + .select(['id', 'slugId']) + .where('spaceId', '=', spaceId) + .where('parentPageId', 'is', null) + .where('deletedAt', 'is', null) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('pageAccess') + .select('pageAccess.id') + .whereRef('pageAccess.pageId', '=', 'pages.id'), + ), + ), + ) + .orderBy('position', (ob) => ob.collate('C').asc()) + .executeTakeFirst(); + } } diff --git a/apps/server/src/database/repos/public-space/public-space.repo.ts b/apps/server/src/database/repos/public-space/public-space.repo.ts new file mode 100644 index 000000000..e6b122fd2 --- /dev/null +++ b/apps/server/src/database/repos/public-space/public-space.repo.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { ExpressionBuilder, sql } from 'kysely'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { dbOrTx } from '../../utils'; +import { PublicSpace } from '@docmost/db/types/entity.types'; +import { DB, Json } from '@docmost/db/types/db'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; +import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination'; + +@Injectable() +export class PublicSpaceRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async findBySpaceId( + spaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('publicSpaces') + .selectAll() + .where('spaceId', '=', spaceId) + .executeTakeFirst(); + } + + async upsert(opts: { + spaceId: string; + workspaceId: string; + enabled: boolean; + searchIndexing?: boolean; + creatorId: string; + settings?: Record; + }): Promise { + const settingsColumn = + typeof opts.settings !== 'undefined' + ? { settings: sql`${JSON.stringify(opts.settings)}::text::jsonb` } + : {}; + + return this.db + .insertInto('publicSpaces') + .values({ + spaceId: opts.spaceId, + workspaceId: opts.workspaceId, + enabled: opts.enabled, + searchIndexing: opts.searchIndexing ?? false, + creatorId: opts.creatorId, + ...settingsColumn, + }) + .onConflict((oc) => + oc.column('spaceId').doUpdateSet({ + enabled: opts.enabled, + updatedAt: new Date(), + ...(typeof opts.searchIndexing !== 'undefined' + ? { searchIndexing: opts.searchIndexing } + : {}), + ...settingsColumn, + }), + ) + .returningAll() + .executeTakeFirst(); + } + + // Published spaces are public by definition, so the list spans the whole + // workspace; userId only resolves the viewer's per-space role. + async getPublishedSpaces( + userId: string, + workspaceId: string, + pagination: PaginationOptions, + ) { + const query = this.db + .selectFrom('publicSpaces') + .select([ + 'id', + 'spaceId', + 'workspaceId', + 'searchIndexing', + 'settings', + 'createdAt', + 'updatedAt', + ]) + .select((eb) => this.withSpace(eb, userId)) + .select((eb) => this.withCreator(eb)) + .where('workspaceId', '=', workspaceId) + .where('enabled', '=', true) + .where(({ exists, selectFrom }) => + exists( + selectFrom('spaces') + .select('spaces.id') + .whereRef('spaces.id', '=', 'publicSpaces.spaceId') + .where('spaces.deletedAt', 'is', null), + ), + ); + + return executeWithCursorPagination(query, { + perPage: pagination.limit, + cursor: pagination.cursor, + beforeCursor: pagination.beforeCursor, + fields: [ + { expression: 'updatedAt', direction: 'desc' }, + { expression: 'id', direction: 'desc' }, + ], + parseCursor: (cursor) => ({ + updatedAt: new Date(cursor.updatedAt), + id: cursor.id, + }), + }); + } + + withSpace(eb: ExpressionBuilder, userId: string) { + return jsonObjectFrom( + eb + .selectFrom('spaces') + .select(['spaces.id', 'spaces.name', 'spaces.slug', 'spaces.logo']) + .select((eb) => this.withUserSpaceRole(eb, userId)) + .whereRef('spaces.id', '=', 'publicSpaces.spaceId'), + ).as('space'); + } + + withUserSpaceRole(eb: ExpressionBuilder, userId: string) { + return eb + .selectFrom( + eb + .selectFrom('spaceMembers') + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('spaceMembers.userId', '=', userId) + .unionAll( + eb + .selectFrom('spaceMembers') + .innerJoin( + 'groupUsers', + 'groupUsers.groupId', + 'spaceMembers.groupId', + ) + .select(['spaceMembers.role']) + .whereRef('spaceMembers.spaceId', '=', 'spaces.id') + .where('groupUsers.userId', '=', userId), + ) + .as('roles_union'), + ) + .select('roles_union.role') + .orderBy( + sql`CASE roles_union.role + WHEN 'admin' THEN 3 + WHEN 'writer' THEN 2 + WHEN 'reader' THEN 1 + ELSE 0 + END`, + 'desc', + ) + .limit(1) + .as('userRole'); + } + + withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.avatarUrl']) + .whereRef('users.id', '=', 'publicSpaces.creatorId'), + ).as('creator'); + } + + async findEnabledWithSpaceByWorkspaceId(workspaceId: string) { + return this.db + .selectFrom('publicSpaces') + .innerJoin('spaces', 'spaces.id', 'publicSpaces.spaceId') + .select([ + 'publicSpaces.settings', + 'publicSpaces.searchIndexing', + 'spaces.name', + 'spaces.slug', + 'spaces.description', + 'spaces.logo', + ]) + .where('publicSpaces.workspaceId', '=', workspaceId) + .where('publicSpaces.enabled', '=', true) + .where('spaces.deletedAt', 'is', null) + .orderBy('spaces.name', 'asc') + .execute(); + } + + async disableByWorkspaceId( + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .updateTable('publicSpaces') + .set({ enabled: false, updatedAt: new Date() }) + .where('workspaceId', '=', workspaceId) + .execute(); + } +} diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts index 82fc170bd..f91aae55e 100644 --- a/apps/server/src/database/repos/space/space.repo.ts +++ b/apps/server/src/database/repos/space/space.repo.ts @@ -33,6 +33,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .select((eb) => this.withIsPublished(eb)) .where('workspaceId', '=', workspaceId); if (isValidUUID(spaceId)) { @@ -52,6 +53,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) + .select((eb) => this.withIsPublished(eb)) .where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -170,6 +172,7 @@ export class SpaceRepo { .selectFrom('spaces') .selectAll('spaces') .select((eb) => [this.withMemberCount(eb)]) + .select((eb) => this.withIsPublished(eb)) .where('workspaceId', '=', workspaceId); if (pagination.query) { @@ -221,6 +224,18 @@ export class SpaceRepo { .as('memberCount'); } + withIsPublished(eb: ExpressionBuilder) { + return eb + .exists( + eb + .selectFrom('publicSpaces') + .select('publicSpaces.id') + .whereRef('publicSpaces.spaceId', '=', 'spaces.id') + .where('publicSpaces.enabled', '=', true), + ) + .as('isPublished'); + } + async deleteSpace(spaceId: string, workspaceId: string): Promise { await this.db .deleteFrom('spaces') diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index a8323d7ce..98608be5c 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -249,6 +249,26 @@ export class WorkspaceRepo { .executeTakeFirst(); } + async updatePublicSpacesSettings( + workspaceId: string, + prefKey: string, + prefValue: string | boolean, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('workspaces') + .set({ + settings: sql`COALESCE(settings, '{}'::jsonb) + || jsonb_build_object('publicSpaces', COALESCE(settings->'publicSpaces', '{}'::jsonb) + || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`, + updatedAt: new Date(), + }) + .where('id', '=', workspaceId) + .returning(this.baseFields) + .executeTakeFirst(); + } + async updateTemplateSettings( workspaceId: string, prefKey: string, diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 4756c2636..0050e32d3 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -74,6 +74,7 @@ export interface Audit { resourceId: string | null; resourceType: string; spaceId: string | null; + userAgent: string | null; workspaceId: string; } @@ -335,6 +336,18 @@ export interface Pages { ydoc: Buffer | null; } +export interface PublicSpaces { + createdAt: Generated; + creatorId: string | null; + enabled: Generated; + id: Generated; + searchIndexing: Generated; + settings: Json | null; + spaceId: string; + updatedAt: Generated; + workspaceId: string; +} + export interface Shares { createdAt: Generated; creatorId: string | null; @@ -361,6 +374,30 @@ export interface SpaceMembers { userId: string | null; } +export interface SiemDestinations { + config: Json; + consecutiveFailures: Generated; + createdAt: Generated; + creatorId: string | null; + cursorCreatedAt: Generated; + cursorId: Generated; + cursorSnapshot: string | null; + enabled: Generated; + failingSince: Timestamp | null; + id: Generated; + lastDeliveredAt: Timestamp | null; + lastError: string | null; + lastErrorAt: Timestamp | null; + name: string; + nextAttemptAt: Timestamp | null; + secrets: string; + status: Generated; + type: string; + updatedAt: Generated; + version: Generated; + workspaceId: string; +} + export interface Spaces { createdAt: Generated; creatorId: string | null; @@ -636,6 +673,63 @@ export interface UserSessions { createdAt: Generated; } +export interface OauthAuthorizationCodes { + clientId: string; + codeChallenge: string | null; + codeChallengeMethod: string | null; + codeHash: string; + consumedAt: Timestamp | null; + createdAt: Generated; + expiresAt: Timestamp; + id: Generated; + redirectUri: string; + scopes: Json; + userId: string; + workspaceId: string; +} + +export interface OauthClients { + clientUri: string | null; + createdAt: Generated; + deletedAt: Timestamp | null; + grantTypes: Json; + id: Generated; + isDynamic: Generated; + logoUri: string | null; + name: string; + redirectUris: Json; + scopes: Json; + secretHash: string | null; + tokenEndpointAuthMethod: Generated; + updatedAt: Generated; + workspaceId: string; +} + +export interface OauthGrants { + clientId: string; + createdAt: Generated; + id: Generated; + lastUsedAt: Timestamp | null; + revokedAt: Timestamp | null; + scopes: Json; + updatedAt: Generated; + userId: string; + workspaceId: string; +} + +export interface OauthTokens { + accessExpiresAt: Timestamp; + accessTokenJti: string; + createdAt: Generated; + grantId: string; + id: Generated; + refreshExpiresAt: Timestamp | null; + refreshTokenHash: string | null; + revokedAt: Timestamp | null; + scopes: Json; + workspaceId: string; +} + export interface DB { aiChats: AiChats; aiChatMessages: AiChatMessages; @@ -665,8 +759,10 @@ export interface DB { pageVerifications: PageVerifications; pageVerifiers: PageVerifiers; pages: Pages; + publicSpaces: PublicSpaces; scimTokens: ScimTokens; shares: Shares; + siemDestinations: SiemDestinations; spaceMembers: SpaceMembers; spaces: Spaces; templates: Templates; @@ -677,4 +773,8 @@ export interface DB { watchers: Watchers; workspaceInvitations: WorkspaceInvitations; workspaces: Workspaces; + oauthAuthorizationCodes: OauthAuthorizationCodes; + oauthClients: OauthClients; + oauthGrants: OauthGrants; + oauthTokens: OauthTokens; } diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index e8aa0572c..1458eef54 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -31,15 +31,21 @@ import { AuthProviders, AuthAccounts, Shares, + PublicSpaces, Favorites, FileTasks, UserMfa as _UserMFA, UserSessions, ApiKeys, ScimTokens, + SiemDestinations, Watchers, Audit as _Audit, Templates, + OauthClients, + OauthAuthorizationCodes, + OauthGrants, + OauthTokens, } from './db'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; @@ -147,6 +153,11 @@ export type Share = Selectable; export type InsertableShare = Insertable; export type UpdatableShare = Updateable>; +// PublicSpace +export type PublicSpace = Selectable; +export type InsertablePublicSpace = Insertable; +export type UpdatablePublicSpace = Updateable>; + // Favorite export type Favorite = Selectable; export type InsertableFavorite = Insertable; @@ -184,6 +195,13 @@ export type ScimToken = Selectable; export type InsertableScimToken = Insertable; export type UpdatableScimToken = Updateable>; +// OAuth +export type OAuthClient = Selectable; +export type InsertableOAuthClient = Insertable; +export type OAuthGrant = Selectable; +export type OAuthAuthorizationCode = Selectable; +export type OAuthToken = Selectable; + // Page Embedding export type PageEmbedding = Selectable; export type InsertablePageEmbedding = Insertable; @@ -256,3 +274,8 @@ export type UpdatableBaseRow = Updateable>; export type BaseView = Selectable; export type InsertableBaseView = Insertable; export type UpdatableBaseView = Updateable>; + +// SIEM destinations +export type SiemDestination = Selectable; +export type InsertableSiemDestination = Insertable; +export type UpdatableSiemDestination = Updateable>; diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index c483df626..96d174012 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -248,6 +248,13 @@ export class EnvironmentService { return disable === 'true'; } + isBetaPublicSpaces(): boolean { + const enabled = this.configService + .get('BETA_PUBLIC_SPACES', 'false') + .toLowerCase(); + return enabled === 'true'; + } + getPostHogHost(): string { return this.configService.get('POSTHOG_HOST'); } @@ -385,4 +392,8 @@ export class EnvironmentService { .map((o) => o.trim()) .filter(Boolean); } + + getAllowedPrivateNetworks(): string { + return this.configService.get('ALLOWED_PRIVATE_NETWORKS', 'none'); + } } diff --git a/apps/server/src/integrations/import/utils/import-formatter.ts b/apps/server/src/integrations/import/utils/import-formatter.ts index 0333b75e7..05e17ca32 100644 --- a/apps/server/src/integrations/import/utils/import-formatter.ts +++ b/apps/server/src/integrations/import/utils/import-formatter.ts @@ -97,6 +97,15 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio) { } } +function isBareLink($el: Cheerio): 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) { normalizeTableColumnWidths($, $root); @@ -104,7 +113,9 @@ export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio) { const $el = $(el); const url = $el.attr('href')!; const { provider } = getEmbedUrlAndProvider(url); - if (provider === 'iframe') return; + if (provider === 'iframe' || !isBareLink($el)) { + return; + } const embed = `
`; $el.replaceWith(embed); diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts new file mode 100644 index 000000000..6efcd645e --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-agent.factory.spec.ts @@ -0,0 +1,23 @@ +import { Agent } from 'undici'; +import { OutboundAgentFactory } from './outbound-agent.factory'; +import { OutboundUrlError } from './outbound-url.guard'; + +describe('OutboundAgentFactory', () => { + it('validates the URL through the guard and returns a releasable undici Agent', async () => { + const validate = jest.fn().mockResolvedValue({ hostname: 'siem.example.com', address: '203.0.113.5', family: 4 }); + const factory = new OutboundAgentFactory({ validate } as any); + + const lease = await factory.lease('https://siem.example.com/ingest', { caCert: undefined, rejectUnauthorized: true }); + + expect(validate).toHaveBeenCalledWith('https://siem.example.com/ingest'); + expect(lease.dispatcher).toBeInstanceOf(Agent); + await expect(lease.release()).resolves.toBeUndefined(); + }); + + it('propagates guard rejections', async () => { + const validate = jest.fn().mockRejectedValue(new OutboundUrlError('Destination URL must use https')); + const factory = new OutboundAgentFactory({ validate } as any); + + await expect(factory.lease('http://siem.example.com')).rejects.toThrow(OutboundUrlError); + }); +}); diff --git a/apps/server/src/integrations/outbound/outbound-agent.factory.ts b/apps/server/src/integrations/outbound/outbound-agent.factory.ts new file mode 100644 index 000000000..d4ac5b471 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-agent.factory.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { Agent, Dispatcher } from 'undici'; +import { OutboundUrlGuard } from './outbound-url.guard'; + +export const OUTBOUND_REQUEST_TIMEOUT_MS = 10_000; + +export type OutboundTlsOptions = { + caCert?: string; // PEM encoded + rejectUnauthorized?: boolean; // Defaults to true; self-hosted only when false. +}; + +export type AgentLease = { + dispatcher: Dispatcher; + release: () => Promise; +}; + +export type IOutboundAgentFactory = { + lease(url: string, tls?: OutboundTlsOptions): Promise; +}; + +/** Creates a per-request agent pinned to the address validated by the SSRF guard. */ +@Injectable() +export class OutboundAgentFactory implements IOutboundAgentFactory { + constructor(private readonly urlGuard: OutboundUrlGuard) {} + + async lease(url: string, tls?: OutboundTlsOptions): Promise { + const pinned = await this.urlGuard.validate(url); + + const lookup = (_hostname: string, options: any, callback: any) => { + if (options?.all) { + callback(null, [{ address: pinned.address, family: pinned.family }]); + } else { + callback(null, pinned.address, pinned.family); + } + }; + + const agent = new Agent({ + connect: { + ca: tls?.caCert || undefined, + rejectUnauthorized: tls?.rejectUnauthorized ?? true, + lookup: lookup as any, + timeout: OUTBOUND_REQUEST_TIMEOUT_MS, + }, + headersTimeout: OUTBOUND_REQUEST_TIMEOUT_MS, + bodyTimeout: OUTBOUND_REQUEST_TIMEOUT_MS, + }); + + return { + dispatcher: agent, + release: async () => { + await agent.close(); + }, + }; + } +} diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts new file mode 100644 index 000000000..b6f8399c8 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-network-policy.spec.ts @@ -0,0 +1,143 @@ +import { + parseOutboundNetworkPolicy, + policyNamesAddress, +} from './outbound-network-policy'; + +describe('parseOutboundNetworkPolicy', () => { + it('parses a bare mode', () => { + expect(parseOutboundNetworkPolicy('all')).toMatchObject({ + mode: 'all', + entries: [], + invalid: false, + }); + expect(parseOutboundNetworkPolicy('none')).toMatchObject({ + mode: 'none', + entries: [], + invalid: false, + }); + }); + + it('treats an empty value as none with no entries', () => { + for (const raw of ['', ' ', ',,']) { + expect(parseOutboundNetworkPolicy(raw)).toMatchObject({ + mode: 'none', + entries: [], + invalid: false, + }); + } + }); + + it('ignores case and surrounding whitespace on the mode', () => { + expect(parseOutboundNetworkPolicy(' ALL ')).toMatchObject({ + mode: 'all', + invalid: false, + }); + }); + + it('parses a mode followed by entries', () => { + const policy = parseOutboundNetworkPolicy('all,127.0.0.0/8,::1/128'); + + expect(policy.mode).toBe('all'); + expect(policy.entries).toHaveLength(2); + expect(policyNamesAddress(policy, '127.0.0.1', 80)).toBe(true); + expect(policyNamesAddress(policy, '127.0.0.1', 8088)).toBe(true); + expect(policyNamesAddress(policy, '::1', 443)).toBe(true); + expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(false); + }); + + it('parses an entry with a port and matches only that port', () => { + const policy = parseOutboundNetworkPolicy('none,192.168.1.20/32:8088'); + + expect(policy.mode).toBe('none'); + expect(policyNamesAddress(policy, '192.168.1.20', 8088)).toBe(true); + expect(policyNamesAddress(policy, '192.168.1.20', 443)).toBe(false); + expect(policyNamesAddress(policy, '192.168.1.21', 8088)).toBe(false); + }); + + it('parses a bracketed IPv6 entry with a port', () => { + const policy = parseOutboundNetworkPolicy('[::1/128]:8088'); + + expect(policy.mode).toBe('none'); + expect(policyNamesAddress(policy, '::1', 8088)).toBe(true); + expect(policyNamesAddress(policy, '::1', 80)).toBe(false); + }); + + it('treats entries without a mode as none plus those entries', () => { + const policy = parseOutboundNetworkPolicy('10.0.0.0/8'); + + expect(policy.mode).toBe('none'); + expect(policy.invalid).toBe(false); + expect(policyNamesAddress(policy, '10.1.2.3', 443)).toBe(true); + expect(policyNamesAddress(policy, '192.168.1.1', 443)).toBe(false); + }); + + it.each([ + 'not-a-cidr', + 'all,not-a-cidr', + 'all,10.0.0.0/8,nonsense', + '10.0.0.0/33', + '10.0.0.0', + '::1/129', + '::1/128:8088', + '10.0.0.0/8:0', + '10.0.0.0/8:70000', + '[::1/128]:notaport', + '0.0.0.0/0', + '::/0', + 'all,0.0.0.0/0', + '[::/0]:8088', + '192.168.1.20/24', + '10.1.0.0/8', + '172.16.0.1/12', + 'fc00::1/7', + '[::1/127]', + '2001:db8::1/32:8088', + ])('fails closed on %s', (raw) => { + expect(parseOutboundNetworkPolicy(raw)).toMatchObject({ + mode: 'none', + entries: [], + invalid: true, + }); + }); + + it.each([ + '10.0.0.0/8', + '172.16.0.0/12', + '100.64.0.0/10', + '192.168.1.20/32', + 'fc00::/7', + 'fe80::/10', + '::1/128', + ])('accepts %s, whose address sits on its prefix boundary', (raw) => { + expect(parseOutboundNetworkPolicy(raw)).toMatchObject({ + entries: [expect.anything()], + invalid: false, + }); + }); + + it('accepts a bracketed IPv6 entry without a port as the unbracketed form', () => { + const bracketed = parseOutboundNetworkPolicy('[::1/128]'); + const bare = parseOutboundNetworkPolicy('::1/128'); + + expect(bracketed).toMatchObject({ mode: 'none', invalid: false }); + for (const port of [80, 443, 8088]) { + expect(policyNamesAddress(bracketed, '::1', port)).toBe( + policyNamesAddress(bare, '::1', port), + ); + expect(policyNamesAddress(bracketed, '::1', port)).toBe(true); + } + }); + + it('never names an address when the value is unparseable or the address is not an IP', () => { + const policy = parseOutboundNetworkPolicy('all,10.0.0.0/8'); + + expect(policyNamesAddress(policy, 'siem.internal', 443)).toBe(false); + expect(policyNamesAddress(parseOutboundNetworkPolicy('garbage'), '10.1.2.3', 443)).toBe(false); + }); + + it('matches an IPv4-mapped IPv6 address against an IPv4 entry', () => { + const policy = parseOutboundNetworkPolicy('127.0.0.0/8'); + + expect(policyNamesAddress(policy, '::ffff:127.0.0.1', 80)).toBe(true); + }); +}); diff --git a/apps/server/src/integrations/outbound/outbound-network-policy.ts b/apps/server/src/integrations/outbound/outbound-network-policy.ts new file mode 100644 index 000000000..11b816530 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-network-policy.ts @@ -0,0 +1,110 @@ +import { BlockList, isIPv4, isIPv6 } from 'node:net'; + +export type OutboundPolicyMode = 'all' | 'none'; + +export type OutboundPolicyEntry = { list: BlockList; port?: number }; + +/** An invalid policy denies all private destinations. */ +export type OutboundNetworkPolicy = { + mode: OutboundPolicyMode; + entries: OutboundPolicyEntry[]; + invalid: boolean; +}; + +function toBytes(address: string, family: 'ipv4' | 'ipv6'): number[] { + if (family === 'ipv4') return address.split('.').map(Number); + + const bytesOf = (part: string): number[] => + part + ? part.split(':').flatMap((group) => { + if (group.includes('.')) return group.split('.').map(Number); + const value = parseInt(group, 16); + return [value >> 8, value & 0xff]; + }) + : []; + + const [head, tail] = address.split('::'); + const headBytes = bytesOf(head); + const tailBytes = address.includes('::') ? bytesOf(tail) : []; + const zeros = new Array(16 - headBytes.length - tailBytes.length).fill(0); + return [...headBytes, ...zeros, ...tailBytes]; +} + +function hasHostBits(bytes: number[], prefix: number): boolean { + return bytes.some((byte, index) => { + const bitsBefore = index * 8; + if (bitsBefore >= prefix) return byte !== 0; + return (byte & (0xff >> Math.min(8, prefix - bitsBefore))) !== 0; + }); +} + +/** Prefix zero is reserved for the explicit `all` mode. */ +function parseCidr( + raw: string, +): { address: string; prefix: number; family: 'ipv4' | 'ipv6' } | null { + const [address, prefixRaw] = raw.split('/'); + if (!prefixRaw) return null; + const prefix = Number(prefixRaw); + if (!Number.isInteger(prefix) || prefix < 1) return null; + const family = isIPv4(address) ? 'ipv4' : isIPv6(address) ? 'ipv6' : null; + if (!family) return null; + if (prefix > (family === 'ipv4' ? 32 : 128)) return null; + if (hasHostBits(toBytes(address, family), prefix)) return null; + return { address, prefix, family }; +} + +/** Parses optional ports without treating IPv6 colons as separators. */ +function splitPort(token: string): { cidr: string; port?: number } { + const bracketed = /^\[(.+)\](?::(\d+))?$/.exec(token); + if (bracketed) { + const [, cidr, port] = bracketed; + return port === undefined ? { cidr } : { cidr, port: Number(port) }; + } + const withPort = /^([^:]+):(\d+)$/.exec(token); + if (withPort) return { cidr: withPort[1], port: Number(withPort[2]) }; + return { cidr: token }; +} + +function parseEntry(token: string): OutboundPolicyEntry | null { + const { cidr: raw, port } = splitPort(token); + if (port !== undefined && (port < 1 || port > 65535)) return null; + const cidr = parseCidr(raw); + if (!cidr) return null; + const list = new BlockList(); + list.addSubnet(cidr.address, cidr.prefix, cidr.family); + return { list, port }; +} + +/** Parses `[all|none,]CIDR[:port],...` and fails closed on invalid input. */ +export function parseOutboundNetworkPolicy(raw: string): OutboundNetworkPolicy { + const tokens = (raw ?? '') + .split(',') + .map((token) => token.trim()) + .filter(Boolean); + if (tokens.length === 0) return { mode: 'none', entries: [], invalid: false }; + + const first = tokens[0].toLowerCase(); + const hasMode = first === 'all' || first === 'none'; + const mode: OutboundPolicyMode = hasMode ? first : 'none'; + + const entries: OutboundPolicyEntry[] = []; + for (const token of hasMode ? tokens.slice(1) : tokens) { + const entry = parseEntry(token); + if (!entry) return { mode: 'none', entries: [], invalid: true }; + entries.push(entry); + } + return { mode, entries, invalid: false }; +} + +export function policyNamesAddress( + policy: OutboundNetworkPolicy, + ip: string, + port: number, +): boolean { + const family = isIPv4(ip) ? 'ipv4' : isIPv6(ip) ? 'ipv6' : null; + if (!family) return false; + return policy.entries.some( + (entry) => + (entry.port === undefined || entry.port === port) && entry.list.check(ip, family), + ); +} diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts new file mode 100644 index 000000000..e480a0928 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-url.guard.spec.ts @@ -0,0 +1,412 @@ +import { Logger } from '@nestjs/common'; +import { + isAlwaysBlockedAddress, + isHardBlockedAddress, + isPrivateAddress, + isPrivateNetworkAddress, + OutboundUrlError, + OutboundUrlGuard, +} from './outbound-url.guard'; + +function guard( + isCloud: boolean, + addresses: Array<{ address: string; family: number }>, + privateNetworks: string = 'none', +) { + return new OutboundUrlGuard( + { + isCloud: () => isCloud, + getAllowedPrivateNetworks: () => privateNetworks, + } as any, + async () => addresses, + ); +} + +function family(ip: string): number { + return ip.includes(':') ? 6 : 4; +} + +describe('isPrivateAddress', () => { + it.each([ + '127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', + '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', + '::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:10.0.0.1', + '0:0:0:0:0:0:0:1', '::ffff:a00:1', '::ffff:7f00:1', '0000:0000:0000:0000:0000:0000:0000:0000', + '192.0.0.1', '192.0.2.1', '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5', + '::a00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1', '100::1', '2001::1', '2001:0:a00:1::1', '2001:db8::1', '2002:a00:1::1', 'fec0::1', + ])('flags %s as private or reserved', (ip) => { + expect(isPrivateAddress(ip)).toBe(true); + }); + + it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '::ffff:8.8.8.8', '::ffff:5db8:d822', '::ffff:8.8.8.8', '2001:4860:4860::8888', '100.128.0.1', '198.17.255.255'])( + 'allows public %s', + (ip) => { + expect(isPrivateAddress(ip)).toBe(false); + }, + ); +}); + +describe('isAlwaysBlockedAddress / isPrivateNetworkAddress', () => { + it.each([ + '0.0.0.0', '127.0.0.1', '169.254.169.254', '192.0.0.1', '192.0.2.1', + '192.88.99.1', '198.18.0.1', '198.51.100.7', '203.0.113.5', '224.0.0.1', + '::1', '::', '::ffff:127.0.0.1', '::ffff:0:7f00:1', '64:ff9b::a00:1', '64:ff9b:1::a00:1', + '100::1', '2001::1', '2001:db8::1', '2002:a00:1::1', 'fe80::1', 'fec0::1', + 'ff02::1', + ])('flags %s as always-blocked but not a private network', (ip) => { + expect(isAlwaysBlockedAddress(ip)).toBe(true); + expect(isPrivateNetworkAddress(ip)).toBe(false); + }); + + it.each([ + '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '100.64.0.1', + 'fc00::1', 'fd12::1', + ])('flags %s as a private network but not always-blocked', (ip) => { + expect(isPrivateNetworkAddress(ip)).toBe(true); + expect(isAlwaysBlockedAddress(ip)).toBe(false); + }); + + it.each(['8.8.8.8', '172.32.0.1', '2606:4700::1111', '100.128.0.1'])( + 'allows public %s in both', + (ip) => { + expect(isAlwaysBlockedAddress(ip)).toBe(false); + expect(isPrivateNetworkAddress(ip)).toBe(false); + }, + ); + + it('the two lists together are exactly isPrivateAddress', () => { + for (const ip of ['10.0.0.5', '127.0.0.1', '8.8.8.8', 'fe80::1', 'fc00::1']) { + expect(isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip)).toBe( + isPrivateAddress(ip), + ); + } + }); +}); + +describe('OutboundUrlGuard.validate', () => { + const publicV4 = { address: '93.184.216.34', family: 4 }; + + it('rejects http on cloud', async () => { + await expect(guard(true, [publicV4]).validate('http://siem.example.com/x')) + .rejects.toThrow(OutboundUrlError); + }); + + it('rejects hosts that resolve to a private range on cloud', async () => { + await expect( + guard(true, [publicV4, { address: '10.0.0.5', family: 4 }]).validate('https://siem.example.com'), + ).rejects.toThrow(/private or reserved/); + }); + + it('rejects the cloud metadata address literal', async () => { + await expect(guard(true, []).validate('https://169.254.169.254/latest')) + .rejects.toThrow(/private or reserved/); + }); + + it('allows LAN hosts and http on self-hosted when private networks are allowed', async () => { + const pinned = await guard(false, [{ address: '10.0.5.20', family: 4 }], 'all') + .validate('http://splunk.internal:8088/services/collector/event'); + expect(pinned).toEqual({ hostname: 'splunk.internal', address: '10.0.5.20', family: 4 }); + }); + + it('pins the first resolved address and keeps the hostname for SNI', async () => { + const pinned = await guard(true, [{ address: '2606:4700::1111', family: 6 }, publicV4]) + .validate('https://siem.example.com'); + expect(pinned).toEqual({ hostname: 'siem.example.com', address: '2606:4700::1111', family: 6 }); + }); + + it('rejects credentials in the URL and unresolvable hosts', async () => { + await expect(guard(false, [publicV4]).validate('https://user:pw@siem.example.com')) + .rejects.toThrow(/credentials/); + await expect(guard(false, []).validate('https://nope.example.com')) + .rejects.toThrow(/Could not resolve/); + }); + + it('marks resolution failures retryable and configuration failures not', async () => { + const throwing = new OutboundUrlGuard( + { isCloud: () => false } as any, + async () => { + throw new Error('EAI_AGAIN'); + }, + ); + + const dnsError = await throwing + .validate('https://siem.example.com') + .catch((e) => e); + expect(dnsError).toBeInstanceOf(OutboundUrlError); + expect(dnsError.retryable).toBe(true); + + const emptyError = await guard(false, []) + .validate('https://nope.example.com') + .catch((e) => e); + expect(emptyError.retryable).toBe(true); + + for (const url of [ + 'not-a-url', + 'ftp://siem.example.com', + 'https://user:pw@siem.example.com', + ]) { + const err = await guard(false, [publicV4]) + .validate(url) + .catch((e) => e); + expect(err).toBeInstanceOf(OutboundUrlError); + expect(err.retryable).toBe(false); + } + + const privateError = await guard(true, [{ address: '10.0.0.5', family: 4 }]) + .validate('https://siem.example.com') + .catch((e) => e); + expect(privateError.retryable).toBe(false); + }); + + const hardBlocked = ['169.254.169.254', '0.0.0.0', 'fe80::1', 'ff02::1']; + const loopbackOrReserved = ['127.0.0.1', '::1', '::ffff:127.0.0.1', '192.0.2.1']; + + it.each(hardBlocked)( + 'self-hosted refuses %s under every ALLOWED_PRIVATE_NETWORKS value', + async (ip) => { + for (const value of ['all', 'none', '169.254.0.0/16', 'all,169.254.0.0/16', 'all,fe80::/10']) { + await expect( + guard(false, [{ address: ip, family: family(ip) }], value).validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/link-local, metadata or reserved address .* which is never allowed/); + } + }, + ); + + it.each(loopbackOrReserved)( + 'self-hosted refuses %s unless an entry names it', + async (ip) => { + for (const value of ['all', 'none']) { + await expect( + guard(false, [{ address: ip, family: family(ip) }], value).validate( + 'http://siem.internal', + ), + ).rejects.toThrow( + /resolves to a loopback or reserved address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/, + ); + } + }, + ); + + it.each(['10.1.2.3', '192.168.1.10'])( + 'self-hosted refuses private network %s by default', + async (ip) => { + await expect( + guard(false, [{ address: ip, family: 4 }]).validate('http://siem.internal'), + ).rejects.toThrow( + /resolves to a private address .* Set ALLOWED_PRIVATE_NETWORKS on the server to allow it/, + ); + }, + ); + + it.each(['10.1.2.3', '192.168.1.10', 'fc00::1', '100.64.0.1'])( + 'all accepts private network %s', + async (ip) => { + const pinned = await guard( + false, + [{ address: ip, family: family(ip) }], + 'all', + ).validate('http://siem.internal'); + expect(pinned.address).toBe(ip); + }, + ); + + it('all still refuses loopback, and a loopback entry opts it back in', async () => { + await expect( + guard(false, [{ address: '127.0.0.1', family: 4 }], 'all').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/loopback or reserved/); + + const allowed = await guard( + false, + [{ address: '127.0.0.1', family: 4 }], + 'all,127.0.0.0/8', + ).validate('http://siem.internal'); + expect(allowed.address).toBe('127.0.0.1'); + + const lan = await guard( + false, + [{ address: '10.1.2.3', family: 4 }], + 'all,127.0.0.0/8', + ).validate('http://siem.internal'); + expect(lan.address).toBe('10.1.2.3'); + + await expect( + guard(false, [{ address: '::1', family: 6 }], 'all,127.0.0.0/8').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/loopback or reserved/); + }); + + it('an entry with a port matches only that port', async () => { + const policy = 'none,192.168.1.20/32:8088'; + + const allowed = await guard( + false, + [{ address: '192.168.1.20', family: 4 }], + policy, + ).validate('https://192.168.1.20:8088/services/collector/event'); + expect(allowed.address).toBe('192.168.1.20'); + + await expect( + guard(false, [{ address: '192.168.1.20', family: 4 }], policy).validate( + 'https://192.168.1.20', + ), + ).rejects.toThrow(/private address/); + + await expect( + guard(false, [{ address: '192.168.1.21', family: 4 }], policy).validate( + 'https://192.168.1.21:8088', + ), + ).rejects.toThrow(/private address/); + }); + + it('a bracketed IPv6 entry with a port accepts only that port', async () => { + const policy = '[::1/128]:8088'; + + const allowed = await guard( + false, + [{ address: '::1', family: 6 }], + policy, + ).validate('http://[::1]:8088/ingest'); + expect(allowed).toEqual({ hostname: '::1', address: '::1', family: 6 }); + + await expect( + guard(false, [{ address: '::1', family: 6 }], policy).validate('http://[::1]/ingest'), + ).rejects.toThrow(/loopback or reserved/); + }); + + it('an entry without a port matches every port', async () => { + for (const url of ['http://127.0.0.1:8088', 'https://127.0.0.1', 'http://127.0.0.1']) { + const allowed = await guard( + false, + [{ address: '127.0.0.1', family: 4 }], + '127.0.0.0/8', + ).validate(url); + expect(allowed.address).toBe('127.0.0.1'); + } + }); + + it('entries without a mode none every private network not named', async () => { + const allowed = await guard( + false, + [{ address: '192.168.1.10', family: 4 }], + '192.168.1.0/24', + ).validate('http://siem.internal'); + expect(allowed.address).toBe('192.168.1.10'); + + await expect( + guard(false, [{ address: '10.1.2.3', family: 4 }], '192.168.1.0/24').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/private address/); + }); + + it('an unparseable value denies everything private or reserved and logs once per process', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const g = guard(false, [{ address: '10.1.2.3', family: 4 }], 'all,10.0.0.0/8, not-a-cidr'); + + await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/); + await expect(g.validate('http://siem.internal')).rejects.toThrow(/private address/); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][0]).toMatch(/ALLOWED_PRIVATE_NETWORKS/); + errorSpy.mockRestore(); + }); + + it('cloud ignores ALLOWED_PRIVATE_NETWORKS and always refuses private and reserved ranges', async () => { + for (const ip of [...hardBlocked, ...loopbackOrReserved, '10.1.2.3', '192.168.1.10']) { + for (const value of ['all', 'none', '127.0.0.0/8', 'all,10.0.0.0/8']) { + await expect( + guard(true, [{ address: ip, family: family(ip) }], value).validate( + 'https://siem.example.com', + ), + ).rejects.toThrow(/private or reserved/); + } + } + }); + + it('refuses a resolved address that is not an IP address', async () => { + await expect( + guard(false, [{ address: 'not-an-ip', family: 4 }], 'all').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/is not an IP address/); + }); + + it('refuses the whole host when any one of its addresses is refused', async () => { + await expect( + guard( + false, + [publicV4, { address: '10.1.2.3', family: 4 }], + 'none', + ).validate('http://siem.internal'), + ).rejects.toThrow(/private address/); + + await expect( + guard( + false, + [publicV4, { address: '127.0.0.1', family: 4 }], + 'all', + ).validate('http://siem.internal'), + ).rejects.toThrow(/loopback or reserved/); + + await expect( + guard( + false, + [publicV4, { address: '169.254.169.254', family: 4 }], + 'all', + ).validate('http://siem.internal'), + ).rejects.toThrow(/never allowed/); + }); + + it('refuses an IPv4-translated loopback address even when private networks are allowed', async () => { + await expect( + guard(false, [{ address: '::ffff:0:7f00:1', family: 6 }], 'all').validate( + 'http://siem.internal', + ), + ).rejects.toThrow(/loopback or reserved/); + }); + + it('a public address is allowed in every mode', async () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + + for (const value of ['all', 'none', '', '192.168.1.0/24', 'garbage']) { + await expect( + guard(false, [publicV4], value).validate('http://siem.example.com'), + ).resolves.toMatchObject({ address: publicV4.address }); + } + await expect( + guard(true, [publicV4], 'all').validate('https://siem.example.com'), + ).resolves.toMatchObject({ address: publicV4.address }); + errorSpy.mockRestore(); + }); +}); + +describe('isHardBlockedAddress', () => { + it.each([ + '0.0.0.0', '169.254.169.254', '224.0.0.1', '255.255.255.255', + '::', 'fe80::1', 'ff02::1', + ])('flags %s as hard-blocked', (ip) => { + expect(isHardBlockedAddress(ip)).toBe(true); + }); + + it.each(['127.0.0.1', '::1', '192.0.2.1', 'fec0::1', '8.8.8.8'])( + 'does not flag %s as hard-blocked (it may still be always-blocked)', + (ip) => { + expect(isHardBlockedAddress(ip)).toBe(false); + }, + ); + + it('is a subset of isAlwaysBlockedAddress', () => { + for (const ip of ['0.0.0.0', '169.254.169.254', 'fe80::1', 'ff02::1']) { + expect(isAlwaysBlockedAddress(ip)).toBe(true); + } + }); +}); diff --git a/apps/server/src/integrations/outbound/outbound-url.guard.ts b/apps/server/src/integrations/outbound/outbound-url.guard.ts new file mode 100644 index 000000000..1c9e4a872 --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound-url.guard.ts @@ -0,0 +1,231 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; +import { promises as dns } from 'node:dns'; +import { BlockList, isIPv4, isIPv6 } from 'node:net'; +import { EnvironmentService } from '../environment/environment.service'; +import { + OutboundNetworkPolicy, + parseOutboundNetworkPolicy, + policyNamesAddress, +} from './outbound-network-policy'; + +export const OUTBOUND_LOOKUP = 'OUTBOUND_LOOKUP'; + +export type ResolvedAddress = { address: string; family: number }; +export type LookupFn = (hostname: string) => Promise; +export type PinnedAddress = { hostname: string; address: string; family: 4 | 6 }; + +/** A rejected URL. Only transient resolution failures are retryable. */ +export class OutboundUrlError extends Error { + constructor( + message: string, + readonly retryable: boolean = false, + ) { + super(message); + this.name = 'OutboundUrlError'; + } +} + +export const defaultLookup: LookupFn = async (hostname) => { + const results = await dns.lookup(hostname, { all: true }); + return results.map((r) => ({ address: r.address, family: r.family })); +}; + +// Reserved ranges blocked unless explicitly allowed on self-hosted deployments. +const ALWAYS_BLOCKED = new BlockList(); +ALWAYS_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4'); // "this" network / unspecified +ALWAYS_BLOCKED.addSubnet('127.0.0.0', 8, 'ipv4'); +ALWAYS_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local / cloud metadata +ALWAYS_BLOCKED.addSubnet('192.0.0.0', 24, 'ipv4'); // IETF protocol assignments +ALWAYS_BLOCKED.addSubnet('192.0.2.0', 24, 'ipv4'); // TEST-NET-1 +ALWAYS_BLOCKED.addSubnet('192.88.99.0', 24, 'ipv4'); // deprecated 6to4 relay anycast +ALWAYS_BLOCKED.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking +ALWAYS_BLOCKED.addSubnet('198.51.100.0', 24, 'ipv4'); // TEST-NET-2 +ALWAYS_BLOCKED.addSubnet('203.0.113.0', 24, 'ipv4'); // TEST-NET-3 +ALWAYS_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4'); // multicast + reserved +ALWAYS_BLOCKED.addSubnet('::', 96, 'ipv6'); // deprecated IPv4-compatible +ALWAYS_BLOCKED.addSubnet('::ffff:0:0:0', 96, 'ipv6'); // IPv4-translated (SIIT): ::ffff:0:7f00:1 is 127.0.0.1 +ALWAYS_BLOCKED.addSubnet('::', 128, 'ipv6'); // unspecified +ALWAYS_BLOCKED.addSubnet('::1', 128, 'ipv6'); // loopback +ALWAYS_BLOCKED.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known prefix +ALWAYS_BLOCKED.addSubnet('64:ff9b:1::', 48, 'ipv6'); // NAT64 local-use +ALWAYS_BLOCKED.addSubnet('100::', 64, 'ipv6'); // discard-only +ALWAYS_BLOCKED.addSubnet('2001::', 32, 'ipv6'); // Teredo +ALWAYS_BLOCKED.addSubnet('2001:db8::', 32, 'ipv6'); // documentation +ALWAYS_BLOCKED.addSubnet('2002::', 16, 'ipv6'); // 6to4 +ALWAYS_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); // link-local +ALWAYS_BLOCKED.addSubnet('fec0::', 10, 'ipv6'); // deprecated site-local +ALWAYS_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); // multicast + +// Private ranges that self-hosted deployments can allow. +const PRIVATE_NETWORKS = new BlockList(); +PRIVATE_NETWORKS.addSubnet('10.0.0.0', 8, 'ipv4'); +PRIVATE_NETWORKS.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT +PRIVATE_NETWORKS.addSubnet('172.16.0.0', 12, 'ipv4'); +PRIVATE_NETWORKS.addSubnet('192.168.0.0', 16, 'ipv4'); +PRIVATE_NETWORKS.addSubnet('fc00::', 7, 'ipv6'); // unique-local + +// These ranges cannot be allowed by policy. +const HARD_BLOCKED = new BlockList(); +HARD_BLOCKED.addSubnet('0.0.0.0', 8, 'ipv4'); +HARD_BLOCKED.addSubnet('169.254.0.0', 16, 'ipv4'); +HARD_BLOCKED.addRange('224.0.0.0', '255.255.255.255', 'ipv4'); +HARD_BLOCKED.addSubnet('::', 128, 'ipv6'); +HARD_BLOCKED.addSubnet('fe80::', 10, 'ipv6'); +HARD_BLOCKED.addSubnet('ff00::', 8, 'ipv6'); + +/** Returns true for reserved or transition ranges. Invalid input is blocked. */ +export function isAlwaysBlockedAddress(ip: string): boolean { + if (isIPv4(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv4'); + if (isIPv6(ip)) return ALWAYS_BLOCKED.check(ip, 'ipv6'); + return true; +} + +/** Returns true for ranges that policy cannot allow. Invalid input is blocked. */ +export function isHardBlockedAddress(ip: string): boolean { + if (isIPv4(ip)) return HARD_BLOCKED.check(ip, 'ipv4'); + if (isIPv6(ip)) return HARD_BLOCKED.check(ip, 'ipv6'); + return true; +} + +/** Returns true for private network ranges. Invalid input is blocked. */ +export function isPrivateNetworkAddress(ip: string): boolean { + if (isIPv4(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv4'); + if (isIPv6(ip)) return PRIVATE_NETWORKS.check(ip, 'ipv6'); + return true; +} + +/** Returns true for addresses blocked by cloud deployments. */ +export function isPrivateAddress(ip: string): boolean { + return isAlwaysBlockedAddress(ip) || isPrivateNetworkAddress(ip); +} + +type Refusal = { + address: string; + kind: 'not-an-ip' | 'hard-blocked' | 'private' | 'reserved'; +}; + +function findRefusal( + resolved: ResolvedAddress[], + port: number, + policy: OutboundNetworkPolicy, +): Refusal | undefined { + for (const { address } of resolved) { + // Reject invalid resolver output before policy checks. + if (!isIPv4(address) && !isIPv6(address)) return { address, kind: 'not-an-ip' }; + if (isHardBlockedAddress(address)) return { address, kind: 'hard-blocked' }; + if (policyNamesAddress(policy, address, port)) continue; + if (isPrivateNetworkAddress(address)) { + if (policy.mode === 'all') continue; + return { address, kind: 'private' }; + } + if (isAlwaysBlockedAddress(address)) return { address, kind: 'reserved' }; + } + return undefined; +} + +function describeRefusal(hostname: string, { address, kind }: Refusal): string { + if (kind === 'not-an-ip') { + return `Destination host "${hostname}" resolved to "${address}", which is not an IP address`; + } + if (kind === 'hard-blocked') { + return `Destination host "${hostname}" resolves to a link-local, metadata or reserved address (${address}), which is never allowed`; + } + const description = + kind === 'private' ? 'a private address' : 'a loopback or reserved address'; + return `Destination host "${hostname}" resolves to ${description} (${address}). Set ALLOWED_PRIVATE_NETWORKS on the server to allow it`; +} + +function effectivePort(url: URL): number { + if (url.port) return Number(url.port); + return url.protocol === 'https:' ? 443 : 80; +} + +@Injectable() +export class OutboundUrlGuard { + private readonly logger = new Logger(OutboundUrlGuard.name); + private readonly lookup: LookupFn; + private cachedPolicy?: { raw: string; policy: OutboundNetworkPolicy }; + + constructor( + private readonly environmentService: EnvironmentService, + @Optional() @Inject(OUTBOUND_LOOKUP) lookup?: LookupFn, + ) { + this.lookup = lookup ?? defaultLookup; + } + + /** Caches the parsed policy and logs each invalid value once. */ + private resolvePolicy(): OutboundNetworkPolicy { + const raw = this.environmentService.getAllowedPrivateNetworks(); + if (this.cachedPolicy?.raw !== raw) { + const policy = parseOutboundNetworkPolicy(raw); + if (policy.invalid) { + this.logger.error( + `Invalid ALLOWED_PRIVATE_NETWORKS value "${raw}"; refusing every private and reserved destination`, + ); + } + this.cachedPolicy = { raw, policy }; + } + return this.cachedPolicy.policy; + } + + /** Validates the URL and returns the address used to pin the connection. */ + async validate(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new OutboundUrlError('Destination URL is not a valid URL'); + } + + const isCloud = this.environmentService.isCloud(); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new OutboundUrlError('Destination URL must use http or https'); + } + if (isCloud && url.protocol !== 'https:') { + throw new OutboundUrlError('Destination URL must use https'); + } + if (url.username || url.password) { + throw new OutboundUrlError('Destination URL must not contain credentials'); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ''); + let resolved: ResolvedAddress[]; + if (isIPv4(hostname) || isIPv6(hostname)) { + resolved = [{ address: hostname, family: isIPv4(hostname) ? 4 : 6 }]; + } else { + try { + resolved = await this.lookup(hostname); + } catch { + throw new OutboundUrlError( + `Could not resolve destination host "${hostname}"`, + true, + ); + } + } + if (resolved.length === 0) { + throw new OutboundUrlError( + `Could not resolve destination host "${hostname}"`, + true, + ); + } + + if (isCloud) { + const blocked = resolved.find((r) => isPrivateAddress(r.address)); + if (blocked) { + throw new OutboundUrlError( + `Destination host "${hostname}" resolves to a private or reserved address (${blocked.address}), which is not allowed`, + ); + } + } else { + const refusal = findRefusal( + resolved, + effectivePort(url), + this.resolvePolicy(), + ); + if (refusal) throw new OutboundUrlError(describeRefusal(hostname, refusal)); + } + + const pick = resolved[0]; + return { hostname, address: pick.address, family: pick.family === 6 ? 6 : 4 }; + } +} diff --git a/apps/server/src/integrations/outbound/outbound.module.ts b/apps/server/src/integrations/outbound/outbound.module.ts new file mode 100644 index 000000000..0c0400dff --- /dev/null +++ b/apps/server/src/integrations/outbound/outbound.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { OutboundAgentFactory } from './outbound-agent.factory'; +import { OutboundUrlGuard } from './outbound-url.guard'; + +@Global() +@Module({ + providers: [OutboundUrlGuard, OutboundAgentFactory], + exports: [OutboundUrlGuard, OutboundAgentFactory], +}) +export class OutboundModule {} diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index 8b7c03a1f..a03c492d4 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -10,6 +10,7 @@ export enum QueueName { NOTIFICATION_QUEUE = '{notification-queue}', AUDIT_QUEUE = '{audit-queue}', BASE_QUEUE = '{base-queue}', + SIEM_QUEUE = '{siem-queue}', } export enum QueueJob { @@ -83,6 +84,9 @@ export enum QueueJob { AUDIT_LOG = 'audit-log', AUDIT_CLEANUP = 'audit-cleanup', + SIEM_SWEEP = 'siem-sweep', + SIEM_DELIVER = 'siem-deliver', + PDF_EXPORT_TASK = 'pdf-export-task', PDF_EXPORT_CLEANUP = 'pdf-export-cleanup', diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index fcb317dbf..77bdf2b41 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -15,6 +15,7 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; connection: { host: redisConfig.host, port: redisConfig.port, + username: redisConfig.username, password: redisConfig.password, db: redisConfig.db, family: redisConfig.family, @@ -93,6 +94,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor'; attempts: 3, }, }), + BullModule.registerQueue({ + name: QueueName.SIEM_QUEUE, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + attempts: 1, + }, + }), BullModule.registerQueue({ name: QueueName.BASE_QUEUE, defaultJobOptions: { diff --git a/apps/server/src/integrations/redis/redis-config.service.ts b/apps/server/src/integrations/redis/redis-config.service.ts index 7f3e90174..c613e0389 100644 --- a/apps/server/src/integrations/redis/redis-config.service.ts +++ b/apps/server/src/integrations/redis/redis-config.service.ts @@ -16,6 +16,7 @@ export class RedisConfigService implements RedisOptionsFactory { config: { host: redisConfig.host, port: redisConfig.port, + username: redisConfig.username, password: redisConfig.password, db: redisConfig.db, family: redisConfig.family, diff --git a/apps/server/src/integrations/static/static.module.ts b/apps/server/src/integrations/static/static.module.ts index 8805804f3..16e3f4cf2 100644 --- a/apps/server/src/integrations/static/static.module.ts +++ b/apps/server/src/integrations/static/static.module.ts @@ -47,6 +47,7 @@ export class StaticModule implements OnModuleInit { BILLING_TRIAL_DAYS: this.environmentService.isCloud() ? this.environmentService.getBillingTrialDays() : undefined, + BETA_PUBLIC_SPACES: this.environmentService.isBetaPublicSpaces(), POSTHOG_HOST: this.environmentService.getPostHogHost(), POSTHOG_KEY: this.environmentService.getPostHogKey(), AI_VECTOR_DRIVER: @@ -71,6 +72,15 @@ export class StaticModule implements OnModuleInit { await app.register(fastifyStatic, { root: clientDistPath, wildcard: false, + setHeaders: (reply: any, pathName: string) => { + // Vite content-hashes everything under /assets, so they can be cached forever + if (/[\\/]assets[\\/]/.test(pathName)) { + reply.header( + 'Cache-Control', + 'public, max-age=31536000, immutable', + ); + } + }, }); app.get(RENDER_PATH, (req: any, res: any) => { diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index 827f671c1..9fa3cb6be 100644 --- a/apps/server/src/integrations/throttle/throttle.module.ts +++ b/apps/server/src/integrations/throttle/throttle.module.ts @@ -4,7 +4,14 @@ 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 } from './throttler-names'; +import { + AUTH_THROTTLER, + AI_CHAT_THROTTLER, + OAUTH_REGISTER_THROTTLER, + OAUTH_TOKEN_THROTTLER, + OAUTH_AUTHORIZE_THROTTLER, + SIEM_TEST_THROTTLER, +} from './throttler-names'; import Redis from 'ioredis'; @Module({ @@ -18,12 +25,17 @@ 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, diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts index 388ba29d7..e0c3b5afa 100644 --- a/apps/server/src/integrations/throttle/throttler-names.ts +++ b/apps/server/src/integrations/throttle/throttler-names.ts @@ -1,2 +1,16 @@ 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 = { + [AUTH_THROTTLER]: true, + [AI_CHAT_THROTTLER]: true, + [OAUTH_REGISTER_THROTTLER]: true, + [OAUTH_TOKEN_THROTTLER]: true, + [OAUTH_AUTHORIZE_THROTTLER]: true, + [SIEM_TEST_THROTTLER]: true, +}; diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx new file mode 100644 index 000000000..8f2fd9834 --- /dev/null +++ b/apps/server/src/integrations/transactional/emails/siem-destination-disabled-email.tsx @@ -0,0 +1,41 @@ +import { Section, Text } from 'react-email'; +import * as React from 'react'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; + +type Props = { + destinationName: string; + destinationType: string; + lastError: string; + failingSince: string; + settingsLink: string; +}; + +export const SiemDestinationDisabledEmail = ({ + destinationName, + destinationType, + lastError, + failingSince, + settingsLink, +}: Props) => { + return ( + +
+ Hi there, + + Your SIEM destination {destinationName} ( + {destinationType}) has been failing since {failingSince} and was + disabled after 24 hours of failed deliveries. + + Last error: {lastError} + + Your events are kept and delivery resumes from where it stopped when + you re-enable it. + +
+ View destination +
+ ); +}; + +export default SiemDestinationDisabledEmail; diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx new file mode 100644 index 000000000..ada3a131d --- /dev/null +++ b/apps/server/src/integrations/transactional/emails/siem-destination-failing-email.tsx @@ -0,0 +1,41 @@ +import { Section, Text } from 'react-email'; +import * as React from 'react'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; + +type Props = { + destinationName: string; + destinationType: string; + lastError: string; + failingSince: string; + settingsLink: string; +}; + +export const SiemDestinationFailingEmail = ({ + destinationName, + destinationType, + lastError, + failingSince, + settingsLink, +}: Props) => { + return ( + +
+ Hi there, + + Docmost cannot deliver audit events to your SIEM destination{' '} + {destinationName} ({destinationType}). + + Last error: {lastError} + Failing since {failingSince}. + + Docmost keeps retrying every 30 minutes. If the destination is still + failing 24 hours after it started, it is disabled automatically. + +
+ View destination +
+ ); +}; + +export default SiemDestinationFailingEmail; diff --git a/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx new file mode 100644 index 000000000..8da53ece0 --- /dev/null +++ b/apps/server/src/integrations/transactional/emails/siem-destination-recovered-email.tsx @@ -0,0 +1,35 @@ +import { Section, Text } from 'react-email'; +import * as React from 'react'; +import { content, paragraph } from '../css/styles'; +import { EmailButton, MailBody } from '../partials/partials'; + +type Props = { + destinationName: string; + destinationType: string; + settingsLink: string; +}; + +export const SiemDestinationRecoveredEmail = ({ + destinationName, + destinationType, + settingsLink, +}: Props) => { + return ( + +
+ Hi there, + + Your SIEM destination {destinationName} ( + {destinationType}) is delivering audit events again. + + + Events buffered during the outage were delivered from where the stream + stopped. + +
+ View destination +
+ ); +}; + +export default SiemDestinationRecoveredEmail; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1c2ccebf1..b51d02941 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -13,7 +13,10 @@ 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 } from './common/helpers'; +import { + resolveFrameHeader, + resolveFrameHeadersForPath, +} from './common/helpers'; async function bootstrap() { const app = await NestFactory.create( @@ -39,7 +42,17 @@ async function bootstrap() { app.useLogger(app.get(PinoLogger)); app.setGlobalPrefix('api', { - exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'], + exclude: [ + 'robots.txt', + 'share/:shareId/p/:pageSlug', + 'mcp', + '.well-known/oauth-authorization-server', + '.well-known/oauth-protected-resource', + '.well-known/oauth-protected-resource/mcp', + 'docs', + 'docs/:spaceSlug', + 'docs/:spaceSlug/:pageSlug', + ], }); const reflector = app.get(Reflector); @@ -57,22 +70,25 @@ async function bootstrap() { environmentService.isIframeEmbedAllowed(), environmentService.getIframeAllowedOrigins(), ); - 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); - }); - } + // Skipped routes: + // /api/files/ - attachment controller sets its own CSP we'd overwrite + // /share/ - public share pages are safe to embed + // /docs/ - public space pages are safe to embed + const frameHeaderSkippedPrefixes = ['/api/files/', '/share/', '/docs/']; + 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); + }); app .getHttpAdapter() diff --git a/package.json b/package.json index f51701448..342ec5001 100644 --- a/package.json +++ b/package.json @@ -31,34 +31,34 @@ "@joplin/turndown": "4.0.82", "@joplin/turndown-plugin-gfm": "1.0.64", "@sindresorhus/slugify": "3.0.0", - "@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/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/y-tiptap": "3.0.7", "bytes": "3.1.2", "cross-env": "10.1.0", @@ -95,5 +95,5 @@ "packages/*" ] }, - "packageManager": "pnpm@11.15.1" + "packageManager": "pnpm@11.25.0" } diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts index 14d70f394..63e2f3460 100644 --- a/packages/editor-ext/src/lib/drawio.ts +++ b/packages/editor-ext/src/lib/drawio.ts @@ -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 } from "./media-utils"; +import { normalizeFileUrl, syncAltBadge } from "./media-utils"; export type DrawioResizeOptions = { enabled: boolean; @@ -293,6 +293,8 @@ export const Drawio = Node.create({ const container = nodeView.dom as HTMLElement; applyAlignment(container, align); + syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt); + currentNode = updatedNode; return true; }, @@ -310,6 +312,8 @@ export const Drawio = Node.create({ const dom = nodeView.dom as HTMLElement; + syncAltBadge(nodeView.wrapper, node.attrs.alt); + applyAlignment(dom, node.attrs.align || "center"); // Handle percentage width backward compat diff --git a/packages/editor-ext/src/lib/excalidraw.ts b/packages/editor-ext/src/lib/excalidraw.ts index 59b28f7fa..de35bde0f 100644 --- a/packages/editor-ext/src/lib/excalidraw.ts +++ b/packages/editor-ext/src/lib/excalidraw.ts @@ -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 } from "./media-utils"; +import { normalizeFileUrl, syncAltBadge } from "./media-utils"; export type ExcalidrawResizeOptions = { enabled: boolean; @@ -293,6 +293,8 @@ export const Excalidraw = Node.create({ const container = nodeView.dom as HTMLElement; applyAlignment(container, align); + syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt); + currentNode = updatedNode; return true; }, @@ -310,6 +312,8 @@ export const Excalidraw = Node.create({ const dom = nodeView.dom as HTMLElement; + syncAltBadge(nodeView.wrapper, node.attrs.alt); + applyAlignment(dom, node.attrs.align || "center"); // Handle percentage width backward compat diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 6a9219264..bafdf82b8 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -7,7 +7,7 @@ import { } from "@tiptap/core"; import { ResizableNodeView } from "../resizable-nodeview"; import type { ResizableNodeViewDirection } from "../resizable-nodeview"; -import { normalizeFileUrl } from "../media-utils"; +import { normalizeFileUrl, syncAltBadge } from "../media-utils"; export type ImageResizeOptions = { enabled: boolean; @@ -316,6 +316,8 @@ export const TiptapImage = Image.extend({ const container = nodeView.dom as HTMLElement; applyAlignment(container, align); + syncAltBadge(nodeView.wrapper, updatedNode.attrs.alt); + currentNode = updatedNode; return true; }, @@ -333,6 +335,8 @@ export const TiptapImage = Image.extend({ const dom = nodeView.dom as HTMLElement; + syncAltBadge(nodeView.wrapper, node.attrs.alt); + // Apply initial alignment applyAlignment(dom, node.attrs.align || "center"); diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 0377ab7f7..d51a54be3 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -61,6 +61,11 @@ export function markdownToHtml( .trimStart(); resetFootnotes(); - const html = marked.parse(markdown).toString(); + // marked always closes fenced code with a newline that the editor keeps as + // an empty trailing line inside the code block. + const html = marked + .parse(markdown) + .toString() + .replace(/\n<\/code><\/pre>/g, ""); return html + renderFootnotesList(); } diff --git a/packages/editor-ext/src/lib/media-utils.ts b/packages/editor-ext/src/lib/media-utils.ts index d18bcb69e..dfec750ca 100644 --- a/packages/editor-ext/src/lib/media-utils.ts +++ b/packages/editor-ext/src/lib/media-utils.ts @@ -7,6 +7,27 @@ export function normalizeFileUrl(src: string): string { return src || ""; } +export function syncAltBadge(wrapper: HTMLElement, alt: unknown): void { + const existing = wrapper.querySelector( + ":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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4dffce5b..1bb461d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,11 +17,12 @@ overrides: express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 + fastify: 5.12.3 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.9 axios: 1.18.1 ip-address: 10.3.1 - fast-uri: 3.1.5 + fast-uri: 3.1.7 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 @@ -62,7 +63,7 @@ importers: version: 4.5.0(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) '@hocuspocus/transformer': specifier: 4.5.0 - version: 4.5.0(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) + version: 4.5.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) '@joplin/turndown': specifier: 4.0.82 version: 4.0.82(supports-color@7.2.0) @@ -73,92 +74,92 @@ importers: specifier: 3.0.0 version: 3.0.0 '@tiptap/core': - specifier: 3.29.2 - version: 3.29.2(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/pm@3.31.3) '@tiptap/extension-audio': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-code-block': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-collaboration': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30) '@tiptap/extension-collaboration-caret': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)) '@tiptap/extension-color': - specifier: 3.29.2 - version: 3.29.2(@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))) '@tiptap/extension-document': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-heading': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-highlight': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-history': - specifier: 3.29.2 - version: 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-image': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-link': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-list': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-placeholder': - specifier: 3.29.2 - version: 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) '@tiptap/extension-subscript': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-superscript': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-table': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-text': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-text-align': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-text-style': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-typography': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/extension-unique-id': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-youtube': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) '@tiptap/html': - specifier: 3.29.2 - version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(happy-dom@20.8.9) + specifier: 3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(happy-dom@20.8.9) '@tiptap/pm': - specifier: 3.29.2 - version: 3.29.2 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/react': - specifier: 3.29.2 - version: 3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tiptap/starter-kit': - specifier: 3.29.2 - version: 3.29.2 + specifier: 3.31.3 + version: 3.31.3 '@tiptap/suggestion': - specifier: 3.29.2 - version: 3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + specifier: 3.31.3 + version: 3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/y-tiptap': specifier: 3.0.7 - version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + version: 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) bytes: specifier: 3.1.2 version: 3.1.2 @@ -212,7 +213,7 @@ importers: version: 9.0.12(yjs@13.6.30) y-prosemirror: specifier: 1.3.7 - version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + version: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) yjs: specifier: ^13.6.30 version: 13.6.30 @@ -268,6 +269,9 @@ importers: '@excalidraw/excalidraw': specifier: 0.18.0-3a5ef40 version: 0.18.0-3a5ef40(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@fontsource-variable/inter': + specifier: 5.3.0 + version: 5.3.0 '@mantine/core': specifier: 9.3.2 version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -397,6 +401,9 @@ importers: socket.io-client: specifier: 4.8.3 version: 4.8.3(supports-color@10.2.2) + yet-another-react-lightbox: + specifier: ^3.32.2 + version: 3.32.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) zod: specifier: 4.3.6 version: 4.3.6 @@ -524,6 +531,9 @@ importers: '@fastify/static': specifier: 10.1.3 version: 10.1.3 + '@jmondi/oauth2-server': + specifier: ^4.3.7 + version: 4.3.7 '@keyv/redis': specifier: 5.1.6 version: 5.1.6(keyv@5.6.0) @@ -2219,6 +2229,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@hocuspocus/common@4.5.0': resolution: {integrity: sha512-hz6IBLLNKOWrWf+8r236CFFhpkcjEVXtZH1XRrkY0b5dYoQgR5gmuV5/5dJm1Vvyc8I70D+SHCvOch2AsRXonA==} @@ -2256,22 +2269,22 @@ packages: peerDependencies: hono: ^4 - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.6': - resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - '@humanwhocodes/retry@0.3.0': - resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} - engines: {node: '>=18.18'} - '@humanwhocodes/retry@0.4.3': resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} @@ -2522,6 +2535,10 @@ packages: resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jmondi/oauth2-server@4.3.7': + resolution: {integrity: sha512-Mo0HHIB/TVRETVSxzvmyclfNPn0HnvSyrLoqvrtlk0z3oWMUr1CFRc7msDNQm6vRJIbRGnCbNKuAwXiNaS6xfw==} + engines: {node: '>=16'} + '@joplin/turndown-plugin-gfm@1.0.64': resolution: {integrity: sha512-8GJ7f9OenE3zkSVII5B6qzIkvgF7C/a20gaASEjM6jWPLPJFFQ2nQ3Ou/kXH1mPUTs9dC9VYs8QXVPvZabKXBQ==} @@ -4103,258 +4120,258 @@ packages: '@types/react-dom': optional: true - '@tiptap/core@3.29.2': - resolution: {integrity: sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==} + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} peerDependencies: - '@tiptap/pm': 3.29.2 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-audio@3.29.2': - resolution: {integrity: sha512-awpsK0X+oWVoTDN8ZZQ6DUSSV+sPEQYeq6nbYJuqlmde0NDv9u9chgqOYLN5xOsk26YqCkVuWL40D+6rWkh89Q==} + '@tiptap/extension-audio@3.31.3': + resolution: {integrity: sha512-ebkcG4s1j4AQJozXaW+FQuoHCP0B2KysJd8GYJyFeIXt6IT7f40+AyVGqD9//CjdmjEbU4JlmLCzB3J+mZJKUw==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-blockquote@3.29.2': - resolution: {integrity: sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg==} + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.29.2': - resolution: {integrity: sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ==} + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-bubble-menu@3.29.2': - resolution: {integrity: sha512-kzcWarcpr031rZu+R3Hzut5uxb3Qj/xxMDEYZIQ3uiq1yb5vEMXj8/SIyWautk6Nu8Rr1leV3rGdR4NzhzyFAA==} + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bullet-list@3.29.2': - resolution: {integrity: sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g==} + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-code-block@3.29.2': - resolution: {integrity: sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ==} + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.29.2': - resolution: {integrity: sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig==} + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-collaboration-caret@3.29.2': - resolution: {integrity: sha512-Mjq4RRgN4rCn+HAkX2lEH7PcY1vnKf4qQetn7Y+7q15IwM/UXStbmhT3YW49AoIWMrA2FApyYlu1dtXGefIa2w==} + '@tiptap/extension-collaboration-caret@3.31.3': + resolution: {integrity: sha512-ub2SsYwmEcmTbqk4aC55M6AnU5/jJcr85lk0jQTqoo1h4ADGYcVXKaJY/CgDCxJgPxr6e/+IiNSYTLjW0nOEgg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@tiptap/y-tiptap': ^3.0.7 - '@tiptap/extension-collaboration@3.29.2': - resolution: {integrity: sha512-KVKwIofMzraf0MxCXUkYd6fNmz0oGKaHDkjhNT6HGHrVzTFDJlZTwHHwSndQS2xKGmZbhWe8C32hvUrFsZXTPw==} + '@tiptap/extension-collaboration@3.31.3': + resolution: {integrity: sha512-JAwOXjeeDYghrPcK57dtvxwn06zcz50mxSSk4PaSdLxM8tkw8+udx+51ewqKKd3WlFof4zyMwUEbO/DGaUdEvw==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@tiptap/y-tiptap': ^3.0.7 yjs: ^13 - '@tiptap/extension-color@3.29.2': - resolution: {integrity: sha512-DMeyDRGZXIe2m2/QlrBoN15R4Ufwz3ERiG//OM7bjcYpiTZiO+pLC6tyabI1+QcJrYUGTu5zVah583xXzt89Kw==} + '@tiptap/extension-color@3.31.3': + resolution: {integrity: sha512-rsZ/HlCYagMoyqn8kKxLYaZwHvowGUTeHVVxEuMJqTantIrnnQMSQUA3a7+X22l2eD7ec/LlG4bBGPjw+gf3tw==} peerDependencies: - '@tiptap/extension-text-style': 3.29.2 + '@tiptap/extension-text-style': 3.31.3 - '@tiptap/extension-document@3.29.2': - resolution: {integrity: sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ==} + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-dropcursor@3.29.2': - resolution: {integrity: sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA==} + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-floating-menu@3.29.2': - resolution: {integrity: sha512-CBTq4Xs5aGWr65uFCIkKBms796OhmirWf/ax//iJ4fl9IfwqjwFuc2vQs9PmuwpKn9ctDHo2sMTtvyeCvIt5LQ==} + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-gapcursor@3.29.2': - resolution: {integrity: sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ==} + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-hard-break@3.29.2': - resolution: {integrity: sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w==} + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-heading@3.29.2': - resolution: {integrity: sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A==} + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-highlight@3.29.2': - resolution: {integrity: sha512-bSeZ1C8OlthyK7BzgtqOGfQymdap9V38W2hbr9KGlhVOIAmTqneOwjm27iFcu7jKqkMArVu6WBYN18Lom6umHQ==} + '@tiptap/extension-highlight@3.31.3': + resolution: {integrity: sha512-97m1nDzTAjJaJZWe5hVOBMC2rjBZvEwTXA39zhjnaE/snb8Cy4H9A3+/sw9HQJBXMoG7tdOPG9kUlM3weYtLcQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-history@3.29.2': - resolution: {integrity: sha512-AMpvoJxz/gqTMAiUo2LOujR44FoICe9ADaSTJMMV+mc+GxAw3YF3Q6frxIKRZkMGMHYDnHLFei7J2fLvCa4ecA==} + '@tiptap/extension-history@3.31.3': + resolution: {integrity: sha512-aHbCh9UyyuesRCXmoeynxa789aEjx/xCbZfx6bzXaSfLuLtnhW/LeUJ6fs2SIejhXgGC7qgUSxcCnbzZGJWBRA==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-horizontal-rule@3.29.2': - resolution: {integrity: sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw==} + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.29.2': - resolution: {integrity: sha512-F+S4iru247mNVZdkNwNDZHeb281DkjsBJinaOGsOyJTvXY+KU5Uq7vY1LQTn493dTfU48Z0yMBUXwJffCT92Mg==} + '@tiptap/extension-image@3.31.3': + resolution: {integrity: sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-italic@3.29.2': - resolution: {integrity: sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA==} + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-link@3.29.2': - resolution: {integrity: sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA==} + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-list-item@3.29.2': - resolution: {integrity: sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ==} + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list-keymap@3.29.2': - resolution: {integrity: sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA==} + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-list@3.29.2': - resolution: {integrity: sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==} + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.29.2': - resolution: {integrity: sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg==} + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} peerDependencies: - '@tiptap/extension-list': 3.29.2 + '@tiptap/extension-list': 3.31.3 - '@tiptap/extension-paragraph@3.29.2': - resolution: {integrity: sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ==} + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-placeholder@3.29.2': - resolution: {integrity: sha512-/BlpPIq2JZk6zLaeYVOXazi6dmd0iRriTymNEnFn1doManN1y5HED9LsMeb/AhNhauL5AgmcHgpvAjbsN9k4SA==} + '@tiptap/extension-placeholder@3.31.3': + resolution: {integrity: sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==} peerDependencies: - '@tiptap/extensions': 3.29.2 + '@tiptap/extensions': 3.31.3 - '@tiptap/extension-strike@3.29.2': - resolution: {integrity: sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg==} + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-subscript@3.29.2': - resolution: {integrity: sha512-3fpp1B2kZWBfmVYZ1ak3yFWiKR3eV9GR/vYGPDMrkZoTdG9LKylRAJs3Fk2gDgTtOPmbrtilGQjl2o55RuoGxw==} + '@tiptap/extension-subscript@3.31.3': + resolution: {integrity: sha512-jUMMtg4QF7wIXIvsgBmgJjdQMETOX4KMF+Nl/0nuCKs59lEi1StOJzkiEdm+UbFhxvvY9B30uLVgzogGIlHHyg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-superscript@3.29.2': - resolution: {integrity: sha512-/bPgdY7zUHtvToHizvzR6Yf4uIWO35cKj9EZsl/vyFb2mXyJ3a2mysCZgZq+5uvhZS/16eNNKOcezlkayhhkMA==} + '@tiptap/extension-superscript@3.31.3': + resolution: {integrity: sha512-WzM84fb8akg1Ni2b1DcE1pE2kkWLbkEVxN2DKElzCVk/kuSNp+VKqOjunHGTnO+kJhXdcmXTVqAO4Vp2kiDxMQ==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-table@3.29.2': - resolution: {integrity: sha512-HY3JWD8i/F8W1oWw56DMqEMqxScIAZFRrhL3rUwiuowHG8LLDqDdQjBV3w1DY8lUZOtNY9grpuMtGlPUFrOVAA==} + '@tiptap/extension-table@3.31.3': + resolution: {integrity: sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-text-align@3.29.2': - resolution: {integrity: sha512-205FF87zjUEh79n6dgVy9SMj1P+rNserHMqPd8lF7YS2TOWikXPG4PFuQHWXXCYzaCvQXAiATSAyPXXmUWUvaQ==} + '@tiptap/extension-text-align@3.31.3': + resolution: {integrity: sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text-style@3.29.2': - resolution: {integrity: sha512-aQad9V9ROaEi3hE4g5z9wQ6lv5FSnzlnWFMVJxRjNlwXgm7H0fymxb4rGfca+pAwYkiRwpKYh1LatpJw2ECQAA==} + '@tiptap/extension-text-style@3.31.3': + resolution: {integrity: sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-text@3.29.2': - resolution: {integrity: sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA==} + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-typography@3.29.2': - resolution: {integrity: sha512-FqgDa9kDfytbh3cSngbU1O9GSYBZQKazqePqJQL6TpjNtt4l9MdnKMZwOSMVsCPJEdFwqymRfE6O7Wtl2bTnZQ==} + '@tiptap/extension-typography@3.31.3': + resolution: {integrity: sha512-Ec2bsAR63dmiLD/5ph8ky3ptHWrBethbPG3a6p2FuB1VJ8LtlocCsDaXR5TvjZkUx2YQIaS2A+yzT5v7cumLgg==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-underline@3.29.2': - resolution: {integrity: sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA==} + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extension-unique-id@3.29.2': - resolution: {integrity: sha512-PboSI7+dxqcAJiWQatPOy70Qzju7eIBqQb+m4Bre9jLpH9KYbEXx+oAWSj3MVCPh0qk0GiHQ8nCQhOPqdEs1Dg==} + '@tiptap/extension-unique-id@3.31.3': + resolution: {integrity: sha512-ztX6wWurh8PLseQ+CfaX+QNKM6ARUSMxkGXUmQPWBiOwwRLGctGc4rhaFIaTsCyQlDl3tXA8Po8H2PQZ9AsqDQ==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-youtube@3.29.2': - resolution: {integrity: sha512-uROfBCyb0BIY0qxm2FdOAGFYpye7frZAPtfY80lKUB9uUdjJuDTs7cRBvPcnaa8+fOTNwC5wUOMhbPQmOUiLSA==} + '@tiptap/extension-youtube@3.31.3': + resolution: {integrity: sha512-pfl3HkmZEOo4zn4As+CfpyH9hNPwm2tneCtAxd9bd6k8jIkHDfbPhJNepaGOfhNiU06j/ckv3pULm3+GSCszhA==} peerDependencies: - '@tiptap/core': 3.29.2 + '@tiptap/core': 3.31.3 - '@tiptap/extensions@3.29.2': - resolution: {integrity: sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==} + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 - '@tiptap/html@3.29.2': - resolution: {integrity: sha512-hgl2JscI407BxcJG/PUMgyrvW5jNz40wtsZNHiYuVPNYzhSAuC4kFFa7dK7MegZ7OnkDppRy30cr9q5hdAtfqA==} + '@tiptap/html@3.31.3': + resolution: {integrity: sha512-kE0/bIopSdECaNX4f/Z+TNgAqWTACFcExJmzhevE7Kx1zsDKcl9rW6poMbOEWdq2HSYxowm3wGbtTHXu6RXZHA==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 happy-dom: ^20.8.9 - '@tiptap/pm@3.29.2': - resolution: {integrity: sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==} + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} - '@tiptap/react@3.29.2': - resolution: {integrity: sha512-ZMKgteYmZXnq7C22U9fbCTC6jAF8XlQM5n2K2FLWCdYAlP4FNV3XeQ9hY2a13KSQ0Q3yltWXZlcWBpt5ClxScg==} + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} peerDependencies: - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.29.2': - resolution: {integrity: sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA==} + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} - '@tiptap/suggestion@3.29.2': - resolution: {integrity: sha512-ZEhRm0gnRCwCScR9IrnIBhm9sr6U8vpR9oeznYk3hiedZ48zsgohqvgoIYpWcwYWrT2Pb0NrfhCT8IuSzdJHzQ==} + '@tiptap/suggestion@3.31.3': + resolution: {integrity: sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.29.2 - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 '@tiptap/y-tiptap@3.0.7': resolution: {integrity: sha512-3VG01F7i2JDghWsBZSBKi7ypMiN5UVVSyD18IwoXx7ilm0ZynrTS+XNpmgxfuiSBjdauWk7tUlP04xfM8Bw4Vw==} @@ -4988,8 +5005,8 @@ packages: resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} engines: {node: '>= 16'} - '@xmldom/xmldom@0.8.13': - resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} '@xtuc/ieee754@1.2.0': @@ -5295,8 +5312,9 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - baseline-browser-mapping@2.9.19: - resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} hasBin: true bcrypt@6.0.0: @@ -5347,8 +5365,8 @@ packages: browser-fs-access@0.29.1: resolution: {integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -5419,8 +5437,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001769: - resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} canvas-roundrect-polyfill@0.0.1: resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==} @@ -6139,8 +6157,8 @@ packages: engines: {node: '>=0.12.18'} hasBin: true - electron-to-chromium@1.5.286: - resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + electron-to-chromium@1.5.420: + resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -6319,6 +6337,7 @@ packages: eslint@9.28.0: resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -6443,8 +6462,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -6463,8 +6482,8 @@ packages: fastify-plugin@6.0.0: resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} - fastify@5.11.3: - resolution: {integrity: sha512-W6hzDP8s0iSeL7LGwY6Oc/ZxuXWOvFEMs6p2L0Si415YRo27W5pBKdOTXxhemBDeSTAcpYf5evRA9onF2OYhPA==} + fastify@5.12.3: + resolution: {integrity: sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==} fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -6481,8 +6500,8 @@ packages: picomatch: optional: true - fflate@0.4.8: - resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.4.9: + resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} @@ -7957,8 +7976,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} nodemailer@9.0.1: resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} @@ -8390,8 +8410,8 @@ packages: peerDependencies: postcss: '>=8.0.0' - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss-simple-vars@7.0.1: @@ -8463,6 +8483,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -8506,8 +8529,8 @@ packages: prosemirror-transform@1.12.0: resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.9: - resolution: {integrity: sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==} + prosemirror-view@1.42.3: + resolution: {integrity: sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} @@ -8538,8 +8561,8 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} query-selector-shadow-dom@1.0.1: @@ -9578,8 +9601,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -10033,6 +10056,20 @@ packages: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} + yet-another-react-lightbox@3.32.2: + resolution: {integrity: sha512-F4HtHQfUNpvkj+AmECgWM4XRdCqMY5gXpKgOUx39+T+FyxLe8II4SK/pwMyYj2X54KH9lFSQeHYY1/GfYf3SdA==} + engines: {node: '>=14'} + peerDependencies: + '@types/react': ^16 || ^17 || ^18 || ^19 + '@types/react-dom': ^16 || ^17 || ^18 || ^19 + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + yjs@13.6.30: resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -10758,7 +10795,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.1 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -11906,7 +11943,7 @@ snapshots: dependencies: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.5 + fast-uri: 3.1.7 '@fastify/busboy@3.1.1': {} @@ -12000,6 +12037,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fontsource-variable/inter@5.3.0': {} + '@hocuspocus/common@4.5.0': dependencies: lib0: 0.2.117 @@ -12030,29 +12069,32 @@ snapshots: transitivePeerDependencies: - srvx - '@hocuspocus/transformer@4.5.0(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': + '@hocuspocus/transformer@4.5.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 - '@tiptap/starter-kit': 3.29.2 - y-prosemirror: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/starter-kit': 3.31.3 + y-prosemirror: 1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) yjs: 13.6.30 '@hono/node-server@2.0.12(hono@4.12.34)': dependencies: hono: 4.12.34 - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.6': + '@humanfs/core@0.19.2': dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.3.0 + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} '@humanwhocodes/module-importer@1.0.1': {} - '@humanwhocodes/retry@0.3.0': {} - '@humanwhocodes/retry@0.4.3': {} '@iconify/types@2.0.0': {} @@ -12394,6 +12436,12 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jmondi/oauth2-server@4.3.7': + dependencies: + jsonwebtoken: 9.0.3 + ms: 2.1.3 + uri-js: 4.4.1 + '@joplin/turndown-plugin-gfm@1.0.64': {} '@joplin/turndown@4.0.82(supports-color@7.2.0)': @@ -12739,7 +12787,7 @@ snapshots: '@nestjs/common': 11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2) '@nestjs/core': 11.2.1(@nestjs/common@11.2.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-querystring: 1.1.2 - fastify: 5.11.3 + fastify: 5.12.3 fastify-plugin: 6.0.0 find-my-way: 9.7.0 light-my-request: 6.6.0 @@ -12824,7 +12872,7 @@ snapshots: '@types/xml-encryption': 1.2.4 '@types/xml2js': 0.4.14 '@xmldom/is-dom-node': 1.0.1 - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 debug: 4.4.3(supports-color@10.2.2) xml-crypto: 6.1.2 xml-encryption: 3.1.0 @@ -14021,200 +14069,200 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@tiptap/core@3.29.2(@tiptap/pm@3.29.2)': + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/pm': 3.29.2 + '@tiptap/pm': 3.31.3 - '@tiptap/extension-audio@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-audio@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-blockquote@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-bold@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-bubble-menu@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.3 - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-bullet-list@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-code-block@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-code@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-collaboration-caret@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))': + '@tiptap/extension-collaboration-caret@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 - '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) - '@tiptap/extension-collaboration@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': + '@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 - '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': 3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30) yjs: 13.6.30 - '@tiptap/extension-color@3.29.2(@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)))': + '@tiptap/extension-color@3.31.3(@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)))': dependencies: - '@tiptap/extension-text-style': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) + '@tiptap/extension-text-style': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) - '@tiptap/extension-document@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-dropcursor@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-floating-menu@3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.3 - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 optional: true - '@tiptap/extension-gapcursor@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-hard-break@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-heading@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-highlight@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-highlight@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-history@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-history@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-horizontal-rule@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-image@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-image@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-italic@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-link@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list-keymap@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-ordered-list@3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-paragraph@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-placeholder@3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2))': + '@tiptap/extension-placeholder@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) - '@tiptap/extension-strike@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-subscript@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-subscript@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-superscript@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-superscript@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-table@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/extension-text-align@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-text-align@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text-style@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-text@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-typography@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-typography@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-underline@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extension-unique-id@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extension-unique-id@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 uuid: 14.0.0 - '@tiptap/extension-youtube@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))': + '@tiptap/extension-youtube@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) - '@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/html@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(happy-dom@20.8.9)': + '@tiptap/html@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(happy-dom@20.8.9)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 happy-dom: 20.8.9 - '@tiptap/pm@3.29.2': + '@tiptap/pm@3.31.3': dependencies: prosemirror-changeset: 2.4.0 prosemirror-commands: 1.7.1 @@ -14228,12 +14276,12 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 - '@tiptap/react@3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tiptap/react@3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) '@types/use-sync-external-store': 0.0.6 @@ -14242,50 +14290,50 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-floating-menu': 3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.29.2': + '@tiptap/starter-kit@3.31.3': dependencies: - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/extension-blockquote': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-bold': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-bullet-list': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-code': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-code-block': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-document': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-dropcursor': 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-gapcursor': 3.29.2(@tiptap/extensions@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-hard-break': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-heading': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-horizontal-rule': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-italic': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-link': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-list': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/extension-list-item': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-list-keymap': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-ordered-list': 3.29.2(@tiptap/extension-list@3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)) - '@tiptap/extension-paragraph': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-strike': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-text': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extension-underline': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2)) - '@tiptap/extensions': 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/suggestion@3.29.2(@floating-ui/dom@1.7.3)(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2)': + '@tiptap/suggestion@3.31.3(@floating-ui/dom@1.7.3)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': dependencies: '@floating-ui/dom': 1.7.3 - '@tiptap/core': 3.29.2(@tiptap/pm@3.29.2) - '@tiptap/pm': 3.29.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 - '@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)': + '@tiptap/y-tiptap@3.0.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)': dependencies: lib0: 0.2.117 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 y-protocols: 1.0.6(yjs@13.6.30) yjs: 13.6.30 @@ -15005,7 +15053,7 @@ snapshots: '@xmldom/is-dom-node@1.0.1': {} - '@xmldom/xmldom@0.8.13': {} + '@xmldom/xmldom@0.8.15': {} '@xtuc/ieee754@1.2.0': {} @@ -15102,7 +15150,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -15378,7 +15426,7 @@ snapshots: base64url@3.0.1: {} - baseline-browser-mapping@2.9.19: {} + baseline-browser-mapping@2.11.20: {} bcrypt@6.0.0: dependencies: @@ -15407,7 +15455,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -15443,13 +15491,13 @@ snapshots: browser-fs-access@0.29.1: {} - browserslist@4.28.1: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - electron-to-chromium: 1.5.286 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.420 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) bs-logger@0.2.6: dependencies: @@ -15520,7 +15568,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001810: {} canvas-roundrect-polyfill@0.0.1: {} @@ -15744,7 +15792,7 @@ snapshots: core-js-compat@3.35.0: dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 core-js@3.43.0: {} @@ -16240,7 +16288,7 @@ snapshots: ejs@5.0.1: {} - electron-to-chromium@1.5.286: {} + electron-to-chromium@1.5.420: {} emittery@0.13.1: {} @@ -16529,7 +16577,7 @@ snapshots: '@eslint/eslintrc': 3.3.5(supports-color@10.2.2) '@eslint/js': 9.28.0 '@eslint/plugin-kit': 0.3.5 - '@humanfs/node': 0.16.6 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.9 @@ -16653,7 +16701,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.2.1 router: 2.2.0(supports-color@10.2.2) send: 1.2.1(supports-color@10.2.2) @@ -16684,7 +16732,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-ref-resolver: 2.0.1 rfdc: 1.3.1 @@ -16693,7 +16741,7 @@ snapshots: '@fastify/merge-json-schemas': 0.2.1 ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) - fast-uri: 3.1.5 + fast-uri: 3.1.7 json-schema-ref-resolver: 3.0.0 rfdc: 1.3.1 @@ -16705,7 +16753,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.5: {} + fast-uri@3.1.7: {} fast-xml-builder@1.2.0: dependencies: @@ -16727,7 +16775,7 @@ snapshots: fastify-plugin@6.0.0: {} - fastify@5.11.3: + fastify@5.12.3: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.0.0 @@ -16739,7 +16787,7 @@ snapshots: find-my-way: 9.7.0 light-my-request: 6.6.0 pino: 10.1.0 - process-warning: 5.0.0 + process-warning: 5.1.0 rfdc: 1.3.1 secure-json-parse: 4.0.0 semver: 7.7.4 @@ -16757,7 +16805,7 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fflate@0.4.8: {} + fflate@0.4.9: {} figures@3.2.0: dependencies: @@ -18160,7 +18208,7 @@ snapshots: mammoth@1.12.0: dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 argparse: 1.0.10 base64-js: 1.5.1 bluebird: 3.4.7 @@ -18361,7 +18409,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.27: {} + node-releases@2.0.54: {} nodemailer@9.0.1: {} @@ -18866,7 +18914,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 2.0.0 pino-std-serializers: 7.0.0 - process-warning: 5.0.0 + process-warning: 5.1.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.4.3 @@ -18931,7 +18979,7 @@ snapshots: postcss-nested@7.0.2(postcss@8.5.25): dependencies: postcss: 8.5.25 - postcss-selector-parser: 7.1.1 + postcss-selector-parser: 7.1.6 postcss-preset-mantine@1.18.0(postcss@8.5.25): dependencies: @@ -18939,7 +18987,7 @@ snapshots: postcss-mixins: 12.1.2(postcss@8.5.25) postcss-nested: 7.0.2(postcss@8.5.25) - postcss-selector-parser@7.1.1: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -18976,7 +19024,7 @@ snapshots: '@posthog/types': 1.390.2 core-js: 3.43.0 dompurify: 3.4.13 - fflate: 0.4.8 + fflate: 0.4.9 preact: 10.29.2 query-selector-shadow-dom: 1.0.1 web-vitals: 5.3.0 @@ -19014,6 +19062,8 @@ snapshots: process-warning@5.0.0: {} + process-warning@5.1.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -19039,20 +19089,20 @@ snapshots: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-gapcursor@1.4.1: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: @@ -19079,7 +19129,7 @@ snapshots: dependencies: prosemirror-model: 1.25.11 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-tables@1.8.5: dependencies: @@ -19087,13 +19137,13 @@ snapshots: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 prosemirror-transform@1.12.0: dependencies: prosemirror-model: 1.25.11 - prosemirror-view@1.41.9: + prosemirror-view@1.42.3: dependencies: prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 @@ -19127,7 +19177,7 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -19909,7 +19959,7 @@ snapshots: stripe@17.7.0: dependencies: '@types/node': 25.5.0 - qs: 6.15.3 + qs: 6.16.0 strnum@2.2.3: {} @@ -19939,7 +19989,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.15.3 + qs: 6.16.0 transitivePeerDependencies: - supports-color @@ -20326,9 +20376,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -20485,7 +20535,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.28.1 + browserslist: 4.28.8 chrome-trace-event: 1.0.3 enhanced-resolve: 5.20.1 es-module-lexer: 2.1.0 @@ -20623,12 +20673,12 @@ snapshots: xml-crypto@6.1.2: dependencies: '@xmldom/is-dom-node': 1.0.1 - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 xpath: 0.0.33 xml-encryption@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.13 + '@xmldom/xmldom': 0.8.15 escape-html: 1.0.3 xpath: 0.0.32 @@ -20671,12 +20721,12 @@ snapshots: lib0: 0.2.117 yjs: 13.6.30 - y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.41.9)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30): + y-prosemirror@1.3.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.3)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30): dependencies: lib0: 0.2.117 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.9 + prosemirror-view: 1.42.3 y-protocols: 1.0.6(yjs@13.6.30) yjs: 13.6.30 @@ -20744,6 +20794,14 @@ snapshots: dependencies: pend: 1.2.0 + yet-another-react-lightbox@3.32.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + yjs@13.6.30: dependencies: lib0: 0.2.117 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 817e408d5..b624cff00 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,11 +16,12 @@ overrides: express-rate-limit: 8.2.2 flatted: 3.4.2 find-my-way: 9.7.0 + fastify: 5.12.3 yaml@>=2.0.0 <2.8.3: 2.8.3 brace-expansion@^5: 5.0.9 axios: 1.18.1 ip-address: 10.3.1 - fast-uri: 3.1.5 + fast-uri: 3.1.7 form-data@>=4.0.0 <4.0.6: 4.0.6 nanoid@>=4.0.0 <5.1.16: 5.1.16 esbuild@>=0.27.3 <0.28.1: 0.28.1 @@ -29,6 +30,10 @@ overrides: js-yaml@>=4.0.0 <4.3.1: 4.3.1 shamefullyHoist: true minimumReleaseAge: 4320 +minimumReleaseAgeExclude: + - '@tiptap/*' + - fastify + - postcss-selector-parser allowBuilds: '@swc/core': true bcrypt: true