Compare commits

..

30 Commits

Author SHA1 Message Date
Philipinho 8a64c43c71 perm share 2026-01-02 01:39:11 +00:00
Philipinho 8eb698648e WIP 5 2025-12-31 10:16:54 +00:00
Philipinho 0c3901abf5 WIP 4 2025-12-29 22:13:58 +00:00
Philipinho c2e722ee5c WIP 3 2025-12-24 00:27:25 +00:00
Philipinho f65726ae26 Fix permission - WIP 2025-12-23 23:05:04 +00:00
Philipinho 68a838606a WIP 2025-12-23 22:41:29 +00:00
Philipinho b0ceae39ba Add page_hierarchy table 2025-12-23 16:05:48 +00:00
Philipinho 732951a322 v0.24.1 2025-12-14 13:24:09 +00:00
Philipinho 2544775266 fix: switch to node slim image 2025-12-14 13:16:40 +00:00
Philipinho d59539f197 fix ai streaming 2025-12-13 14:15:41 +00:00
Philipinho b061df7f7d Use new fastify router options 2025-12-13 14:15:06 +00:00
Philipinho 0fe1459864 fix: override jsonwebtoken version 2025-12-12 17:25:27 +00:00
Philipinho 6af7956889 v0.24.0 2025-12-12 17:15:59 +00:00
Philip Okugbe 3dbb957bd7 New Crowdin updates (#1541)
* New translations translation.json (Dutch)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Russian)

* New translations translation.json (German)

* New translations translation.json (German)

* New translations translation.json (German)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Russian)

* New translations translation.json (Spanish)

* New translations translation.json (Korean)

* New translations translation.json (Korean)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2025-12-12 17:15:19 +00:00
Philipinho f39a4cf2d5 fix space modal spacing 2025-12-12 14:08:30 +00:00
Philipinho 724e01bd55 fix default page share state (API) 2025-12-11 20:43:26 +00:00
Philip Okugbe 6e350f6746 fix nodeview dragging (#1775) 2025-12-11 19:32:18 +00:00
Philip Okugbe cb9f27da9a fix mermaid security (#1774) 2025-12-11 16:44:52 +00:00
Philip Okugbe d2629afff2 feat: anchor links (#1765)
* feat: add heading extension with unique ID support and scroll functionality
* Added unique id for heading
* remove baseUrl heading storage
* move heading to extensions package
* WIP
* support anchors in mentions
* enhance scrolling functionality
* nodeId function
* fix nanoid import
* Bring unique-id extension local
* fixes
* fix internal link scroll in public pages
* add unique id server side
* rename mention anchor to anchorId
* capture first anchorId on paste

---------

Co-authored-by: Romik <40670677+RomikMakavana@users.noreply.github.com>
2025-12-06 14:46:54 +00:00
Philip Okugbe 9139d393ef fix: update tiptap packages (#1755)
* update tiptap version

* create empty paragraph on enter

* feat: split title text into page content on Enter

* update hocuspocus
2025-12-02 13:15:19 +00:00
Philipinho ab96672ecd fix 2025-12-02 13:14:03 +00:00
Philipinho 2ea3c2da58 sync 2025-12-01 14:05:59 +00:00
Philip Okugbe 9fb16bc842 feat(EE): AI vector search (#1691)
* WIP

* AI module - init

* WIP

* sync

* WIP

* refactor naming

* new columns

* sync

* sync

* fix search bug

* stream response

* WIP

* feat embeddings sync

* refine

* Add workspaceId to page events

* refine

* WIP

* add translation string

* sync

* reset ai answer on query change

* hide AI search in cloud

* capture streaming error

* sync
2025-12-01 11:50:25 +00:00
Philip Okugbe c3b350d943 fix: zip extraction validation (#1753)
* fix: zip extraction validation

* fix
2025-12-01 11:37:59 +00:00
Philip Okugbe 8014ba3ab7 feat: Text background highlight (#1754)
* #1196/feat: add text background highlight

* unify text color

* dark mode support
* unify text color and highlight

* dark mode support for color selector trigger

* fix see through in color selector dark mode

* fix selection highlight in dark mode

* brown color

* clean up

---------

Co-authored-by: sanua356 <sanek.pankratov356@gmail.com>
2025-12-01 11:34:35 +00:00
Philipinho ec3a04f7c7 fix 2025-11-29 12:37:35 +00:00
Philip Okugbe 04a17c9b92 package security updates (#1744)
* package security updates

* package updates
2025-11-29 11:50:20 +00:00
Philip Okugbe 520c07a0bc fix: generic page import hierarchy (#1747)
* fix page hierarchy

* fix
2025-11-29 11:50:02 +00:00
Philipinho 60a8ed6826 sync 2025-10-25 02:08:29 +01:00
Philip Okugbe f5684b792e fix duplicated page parenting (#1692) 2025-10-23 15:00:11 +01:00
123 changed files with 7073 additions and 1761 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS base FROM node:22-slim AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost" LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
FROM base AS builder FROM base AS builder
@@ -13,7 +13,9 @@ RUN pnpm build
FROM base AS installer FROM base AS installer
RUN apk add --no-cache curl bash RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.23.2", "version": "0.24.1",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -27,7 +27,7 @@
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3", "@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
"axios": "^1.9.0", "axios": "^1.13.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -57,7 +57,7 @@
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.18", "tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.56" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
@@ -65,10 +65,10 @@
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/node": "22.10.0", "@types/node": "22.19.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
@@ -81,6 +81,6 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"typescript-eslint": "^8.17.0", "typescript-eslint": "^8.17.0",
"vite": "^6.3.5" "vite": "^7.2.4"
} }
} }
@@ -42,7 +42,7 @@
"Delete group": "Gruppe löschen", "Delete group": "Gruppe löschen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.", "Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sind Sie sicher, dass Sie diese Seite löschen möchten? Dadurch werden ihre Unterseiten und die Seitengeschichte gelöscht. Diese Aktion ist unwiderruflich.",
"Description": "Beschreibung", "Description": "Beschreibung",
"Details": "Einzelheiten", "Details": "Details",
"e.g ACME": "z.B. ACME", "e.g ACME": "z.B. ACME",
"e.g ACME Inc": "z.B. ACME Inc.", "e.g ACME Inc": "z.B. ACME Inc.",
"e.g Developers": "z.B. Entwickler", "e.g Developers": "z.B. Entwickler",
@@ -234,9 +234,7 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.", "Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink", "Invite link": "Einladungslink",
"Copy": "Kopieren", "Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert", "Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen", "Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen", "Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.", "Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@@ -527,5 +525,47 @@
"Delete SSO provider": "SSO-Anbieter löschen", "Delete SSO provider": "SSO-Anbieter löschen",
"Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?", "Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?",
"Action": "Aktion", "Action": "Aktion",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration" "{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration",
"Icon": "Icon",
"Upload image": "Bild hochladen",
"Remove image": "Bild entfernen",
"Failed to remove image": "Fehler beim Entfernen des Bildes",
"Image exceeds 10MB limit.": "Bild überschreitet das Limit von 10 MB.",
"Image removed successfully": "Bild erfolgreich entfernt",
"API key": "API-Schlüssel",
"API key created successfully": "API-Schlüssel erfolgreich erstellt",
"API keys": "API-Schlüssel",
"API management": "API-Verwaltung",
"Are you sure you want to revoke this API key": "Sind Sie sicher, dass Sie diesen API-Schlüssel widerrufen möchten?",
"Create API Key": "API-Schlüssel erstellen",
"Custom expiration date": "Benutzerdefiniertes Ablaufdatum",
"Enter a descriptive token name": "Geben Sie einen beschreibenden Token-Namen ein",
"Expiration": "Ablauf",
"Expired": "Abgelaufen",
"Expires": "Läuft ab",
"I've saved my API key": "Ich habe meinen API-Schlüssel gespeichert",
"Last use": "Zuletzt verwendet",
"No API keys found": "Keine API-Schlüssel gefunden",
"No expiration": "Kein Ablauf",
"Revoke API key": "API-Schlüssel widerrufen",
"Revoked successfully": "Erfolgreich widerrufen",
"Select expiration date": "Ablaufdatum wählen",
"This action cannot be undone. Any applications using this API key will stop working.": "Diese Aktion kann nicht rückgängig gemacht werden. Alle Anwendungen, die diesen API-Schlüssel verwenden, werden nicht mehr funktionieren.",
"Update API key": "API-Schlüssel aktualisieren",
"Manage API keys for all users in the workspace": "Verwalten Sie API-Schlüssel für alle Benutzer im Arbeitsbereich",
"AI settings": "KI-Einstellungen",
"AI search": "KI-Suche",
"AI Answer": "KI-Antwort",
"Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...",
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Sources": "Quellen",
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar",
"No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe",
"Highlight color": "Hervorhebungsfarbe",
"Remove color": "Farbe entfernen"
} }
@@ -554,5 +554,20 @@
"Select expiration date": "Select expiration date", "Select expiration date": "Select expiration date",
"This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.", "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
"Update API key": "Update API key", "Update API key": "Update API key",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace" "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"AI settings": "AI settings",
"AI search": "AI search",
"AI Answer": "AI Answer",
"Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...",
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Sources": "Sources",
"Ask AI not available for attachments": "Ask AI not available for attachments",
"No answer available": "No answer available",
"Background color": "Background color",
"Highlight color": "Highlight color",
"Remove color": "Remove color"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "Eliminar proveedor de SSO", "Delete SSO provider": "Eliminar proveedor de SSO",
"Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?", "Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?",
"Action": "Acción", "Action": "Acción",
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}",
"Icon": "Icono",
"Upload image": "Subir imagen",
"Remove image": "Eliminar imagen",
"Failed to remove image": "No se ha podido eliminar la imagen",
"Image exceeds 10MB limit.": "La imagen excede del límite de 10 MB",
"Image removed successfully": "Imagen eliminada correctamente",
"API key": "Clave API",
"API key created successfully": "Clave API creada correctamente",
"API keys": "Claves API",
"API management": "Gestión de API",
"Are you sure you want to revoke this API key": "¿Está seguro de que desea revocar esta clave API? ",
"Create API Key": "Crear clave API",
"Custom expiration date": "Fecha de vencimiento personalizada",
"Enter a descriptive token name": "Introduce un nombre descriptivo del token",
"Expiration": "Vencimiento",
"Expired": "Vencido",
"Expires": "Vence",
"I've saved my API key": "He guardado mi clave API",
"Last use": "Último uso",
"No API keys found": "No se han encontrado claves API",
"No expiration": "Sin vencimiento",
"Revoke API key": "Revocar clave API",
"Revoked successfully": "Revocada correctamente",
"Select expiration date": "Seleccionar fecha de vencimiento",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta acción no se puede deshacer. Las aplicaciones que utilicen esta clave API dejarán de funcionar.",
"Update API key": "Actualizar clave API",
"Manage API keys for all users in the workspace": "Gestionar claves API para todos los usuarios en el espacio de trabajo",
"AI settings": "Configuración de IA",
"AI search": "Búsqueda de IA",
"AI Answer": "Respuesta de IA",
"Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Sources": "Fuentes",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos",
"No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo",
"Highlight color": "Color de resaltado",
"Remove color": "Eliminar color"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "Supprimer le fournisseur SSO", "Delete SSO provider": "Supprimer le fournisseur SSO",
"Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?", "Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?",
"Action": "Action", "Action": "Action",
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}",
"Icon": "Icône",
"Upload image": "Téléverser une image",
"Remove image": "Supprimer l'image",
"Failed to remove image": "Échec de la suppression de l'image",
"Image exceeds 10MB limit.": "L'image dépasse la limite de 10 Mo.",
"Image removed successfully": "Image supprimée avec succès",
"API key": "Clé API",
"API key created successfully": "Clé API créée avec succès",
"API keys": "Clés API",
"API management": "Gestion des API",
"Are you sure you want to revoke this API key": "Êtes-vous sûr de vouloir révoquer cette clé API",
"Create API Key": "Créer une clé API",
"Custom expiration date": "Date d'expiration personnalisée",
"Enter a descriptive token name": "Entrez un nom descriptif pour le jeton",
"Expiration": "Expiration",
"Expired": "Expiré(e)",
"Expires": "Expire",
"I've saved my API key": "J'ai enregistré ma clé API",
"Last use": "Dernière utilisation",
"No API keys found": "Aucune clé API trouvée",
"No expiration": "Pas d'expiration",
"Revoke API key": "Révoquer la clé API",
"Revoked successfully": "Révoqué(e) avec succès",
"Select expiration date": "Sélectionnez la date d'expiration",
"This action cannot be undone. Any applications using this API key will stop working.": "Cette action ne peut pas être annulée. Toutes les applications utilisant cette clé API cesseront de fonctionner.",
"Update API key": "Mettre à jour la clé API",
"Manage API keys for all users in the workspace": "Gérer les clés API pour tous les utilisateurs dans l'espace de travail",
"AI settings": "Paramètres de l'IA",
"AI search": "Recherche IA",
"AI Answer": "Réponse IA",
"Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Sources": "Sources",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes",
"No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "Elimina provider SSO", "Delete SSO provider": "Elimina provider SSO",
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?", "Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
"Action": "Azione", "Action": "Azione",
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}",
"Icon": "Icona",
"Upload image": "Carica immagine",
"Remove image": "Rimuovi immagine",
"Failed to remove image": "Rimozione immagine fallita",
"Image exceeds 10MB limit.": "L'immagine supera il limite di 10MB.",
"Image removed successfully": "Immagine rimossa con successo",
"API key": "Chiave API",
"API key created successfully": "Chiave API creata con successo",
"API keys": "Chiavi API",
"API management": "Gestione API",
"Are you sure you want to revoke this API key": "Sei sicuro di voler revocare questa chiave API",
"Create API Key": "Crea Chiave API",
"Custom expiration date": "Data di scadenza personalizzata",
"Enter a descriptive token name": "Inserisci un nome descrittivo del token",
"Expiration": "Scadenza",
"Expired": "Scaduto",
"Expires": "Scade",
"I've saved my API key": "Ho salvato la mia chiave API",
"Last use": "Ultimo utilizzo",
"No API keys found": "Nessuna chiave API trovata",
"No expiration": "Nessuna scadenza",
"Revoke API key": "Revoca chiave API",
"Revoked successfully": "Revocata con successo",
"Select expiration date": "Seleziona la data di scadenza",
"This action cannot be undone. Any applications using this API key will stop working.": "Questa azione non può essere annullata. Qualsiasi applicazione che utilizza questa chiave API smetterà di funzionare.",
"Update API key": "Aggiorna chiave API",
"Manage API keys for all users in the workspace": "Gestisci le chiavi API per tutti gli utenti nell'area di lavoro",
"AI settings": "Impostazioni AI",
"AI search": "Ricerca AI",
"AI Answer": "Risposta AI",
"Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...",
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Sources": "Fonti",
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati",
"No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo",
"Highlight color": "Colore evidenziato",
"Remove color": "Rimuovi colore"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "SSOプロバイダーを削除する", "Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?", "Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか?",
"Action": "アクション", "Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成" "{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成",
"Icon": "アイコン",
"Upload image": "画像をアップロード",
"Remove image": "画像を削除",
"Failed to remove image": "画像の削除に失敗しました",
"Image exceeds 10MB limit.": "画像が10MBの制限を超えています。",
"Image removed successfully": "画像が正常に削除されました",
"API key": "APIキー",
"API key created successfully": "APIキーが正常に作成されました",
"API keys": "APIキー",
"API management": "API管理",
"Are you sure you want to revoke this API key": "このAPIキーを無効にしてもよろしいですか",
"Create API Key": "APIキーを作成",
"Custom expiration date": "カスタム有効期限",
"Enter a descriptive token name": "説明的なトークン名を入力してください",
"Expiration": "有効期限",
"Expired": "期限切れ",
"Expires": "期限が切れます",
"I've saved my API key": "APIキーを保存しました",
"Last use": "最終使用",
"No API keys found": "APIキーが見つかりません",
"No expiration": "期限なし",
"Revoke API key": "APIキーを無効にする",
"Revoked successfully": "正常に無効化されました",
"Select expiration date": "有効期限を選択してください",
"This action cannot be undone. Any applications using this API key will stop working.": "この操作は元に戻せません。このAPIキーを使用しているアプリケーションは動作を停止します。",
"Update API key": "APIキーを更新",
"Manage API keys for all users in the workspace": "ワークスペース内のすべてのユーザーのAPIキーを管理",
"AI settings": "AI設定",
"AI search": "AI検索",
"AI Answer": "AI回答",
"Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用して、ワークスペースコンテンツ全体にわたって意味検索機能を提供します。",
"Toggle AI search": "AI検索を切り替え",
"Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません",
"No answer available": "回答がありません",
"Background color": "背景色",
"Highlight color": "ハイライト色",
"Remove color": "色を削除"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "SSO 제공자 삭제", "Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?", "Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업", "Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성" "{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성",
"Icon": "아이콘",
"Upload image": "이미지 업로드",
"Remove image": "이미지 제거",
"Failed to remove image": "이미지 제거 실패",
"Image exceeds 10MB limit.": "이미지가 10MB 용량 제한을 초과합니다.",
"Image removed successfully": "이미지가 성공적으로 제거되었습니다",
"API key": "API 키",
"API key created successfully": "API 키 생성 완료",
"API keys": "API 키",
"API management": "API 관리",
"Are you sure you want to revoke this API key": "이 API 키를 취소하시겠습니까?",
"Create API Key": "API 키 생성",
"Custom expiration date": "사용자 정의 만료일",
"Enter a descriptive token name": "토큰 이름을 입력하세요",
"Expiration": "만료",
"Expired": "만료됨",
"Expires": "만료일",
"I've saved my API key": "API 키를 저장했습니다",
"Last use": "최근 사용",
"No API keys found": "API 키를 찾을 수 없습니다",
"No expiration": "유효기간 없음",
"Revoke API key": "API 키 취소",
"Revoked successfully": "성공적으로 취소되었습니다",
"Select expiration date": "만료일 선택",
"This action cannot be undone. Any applications using this API key will stop working.": "이 작업은 되돌릴 수 없습니다. 이 API 키를 사용하는 모든 응용 프로그램이 작동을 멈출 것입니다.",
"Update API key": "API 키 갱신",
"Manage API keys for all users in the workspace": "워크스페이스 내 모든 사용자의 API 키 관리",
"AI settings": "AI 설정",
"AI search": "AI 검색",
"AI Answer": "AI 답변",
"Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Sources": "출처",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색",
"Highlight color": "강조 색",
"Remove color": "색 제거"
} }
@@ -34,7 +34,7 @@
"Create group": "Groep aanmaken", "Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken", "Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken", "Create space": "Ruimte aanmaken",
"Create workspace": "Wwerkruimte aanmaken", "Create workspace": "Werkruimte aanmaken",
"Current password": "Huidig wachtwoord", "Current password": "Huidig wachtwoord",
"Dark": "Donker", "Dark": "Donker",
"Date": "Datum", "Date": "Datum",
@@ -91,7 +91,7 @@
"Invite by email": "Uitnodigen via e-mail", "Invite by email": "Uitnodigen via e-mail",
"Invite members": "Leden uitnodigen", "Invite members": "Leden uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen", "Invite new members": "Nieuwe leden uitnodigen",
"Invited members who are yet to accept their invitation will appear here.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.", "Invited members who are yet to accept their invitation will appear here.": "Uitgenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft", "Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft",
"Join the workspace": "Word lid van de werkruimte", "Join the workspace": "Word lid van de werkruimte",
"Language": "Taal", "Language": "Taal",
@@ -527,5 +527,47 @@
"Delete SSO provider": "Verwijder SSO-provider", "Delete SSO provider": "Verwijder SSO-provider",
"Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?", "Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?",
"Action": "Actie", "Action": "Actie",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie" "{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie",
"Icon": "Icoon",
"Upload image": "Afbeelding uploaden",
"Remove image": "Afbeelding verwijderen",
"Failed to remove image": "Afbeelding verwijderen mislukt",
"Image exceeds 10MB limit.": "Afbeelding overschrijdt de limiet van 10MB.",
"Image removed successfully": "Afbeelding succesvol verwijderd",
"API key": "API-sleutel",
"API key created successfully": "API-sleutel succesvol aangemaakt",
"API keys": "API-sleutels",
"API management": "API-beheer",
"Are you sure you want to revoke this API key": "Weet u zeker dat u deze API-sleutel wilt intrekken",
"Create API Key": "API-sleutel aanmaken",
"Custom expiration date": "Aangepaste vervaldatum",
"Enter a descriptive token name": "Voer een beschrijvende tokennaam in",
"Expiration": "Vervaldatum",
"Expired": "Verlopen",
"Expires": "Verloopt",
"I've saved my API key": "Ik heb mijn API-sleutel opgeslagen",
"Last use": "Laatst gebruikt",
"No API keys found": "Geen API-sleutels gevonden",
"No expiration": "Geen vervaldatum",
"Revoke API key": "API-sleutel intrekken",
"Revoked successfully": "Succesvol ingetrokken",
"Select expiration date": "Selecteer vervaldatum",
"This action cannot be undone. Any applications using this API key will stop working.": "Deze actie kan niet ongedaan worden gemaakt. Alle toepassingen die deze API-sleutel gebruiken, zullen niet meer werken.",
"Update API key": "API-sleutel bijwerken",
"Manage API keys for all users in the workspace": "Beheer API-sleutels voor alle gebruikers in de werkruimte",
"AI settings": "AI-instellingen",
"AI search": "AI-zoekopdracht",
"AI Answer": "AI Antwoord",
"Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Sources": "Bronnen",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages",
"No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur",
"Remove color": "Kleur verwijderen"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "Excluir provedor de SSO", "Delete SSO provider": "Excluir provedor de SSO",
"Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?", "Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?",
"Action": "Ação", "Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}",
"Icon": "Ícone",
"Upload image": "Fazer upload da imagem",
"Remove image": "Remover imagem",
"Failed to remove image": "Falha ao remover imagem",
"Image exceeds 10MB limit.": "A imagem excede o limite de 10MB.",
"Image removed successfully": "Imagem removida com sucesso",
"API key": "Chave API",
"API key created successfully": "Chave API criada com sucesso",
"API keys": "Chaves API",
"API management": "Gestão de API",
"Are you sure you want to revoke this API key": "Tem certeza de que deseja revogar esta chave API",
"Create API Key": "Criar Chave API",
"Custom expiration date": "Data de expiração personalizada",
"Enter a descriptive token name": "Insira um nome descritivo para o token",
"Expiration": "Expiração",
"Expired": "Expirado",
"Expires": "Expira",
"I've saved my API key": "Salvei minha chave API",
"Last use": "Último uso",
"No API keys found": "Nenhuma chave API encontrada",
"No expiration": "Sem expiração",
"Revoke API key": "Revogar chave API",
"Revoked successfully": "Revogada com sucesso",
"Select expiration date": "Selecionar data de expiração",
"This action cannot be undone. Any applications using this API key will stop working.": "Esta ação não pode ser desfeita. Qualquer aplicação usando esta chave API deixará de funcionar.",
"Update API key": "Atualizar chave API",
"Manage API keys for all users in the workspace": "Gerenciar chaves API para todos os usuários no espaço de trabalho",
"AI settings": "Configurações de IA",
"AI search": "Pesquisa IA",
"AI Answer": "Resposta de IA",
"Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Sources": "Fontes",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos",
"No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo",
"Highlight color": "Cor de destaque",
"Remove color": "Remover cor"
} }
@@ -501,7 +501,7 @@
"Failed to load subpages": "Не удалось загрузить под страницы", "Failed to load subpages": "Не удалось загрузить под страницы",
"No subpages": "Нет подстраниц", "No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (вложенные страницы)", "Subpages (Child pages)": "Подстраницы (вложенные страницы)",
"List all subpages of the current page": "Показать все подстраницы текущей страницы", "List all subpages of the current page": "Показать все под страницы",
"Attachments": "Вложения", "Attachments": "Вложения",
"All spaces": "Все пространства", "All spaces": "Все пространства",
"Unknown": "Неизвестно", "Unknown": "Неизвестно",
@@ -527,5 +527,47 @@
"Delete SSO provider": "Удалить поставщика SSO", "Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?", "Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие", "Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}",
"Icon": "Иконка",
"Upload image": "Загрузить изображение",
"Remove image": "Удалить изображение",
"Failed to remove image": "Не удалось удалить изображение",
"Image exceeds 10MB limit.": "Изображение превышает предел 10MB.",
"Image removed successfully": "Изображение успешно удалено",
"API key": "API ключ",
"API key created successfully": "API ключ успешно создан",
"API keys": "API ключи",
"API management": "Управление API",
"Are you sure you want to revoke this API key": "Вы уверены, что хотите отозвать этот API ключ",
"Create API Key": "Создать API ключ",
"Custom expiration date": "Пользовательская дата срока действия",
"Enter a descriptive token name": "Введите понятное имя токена",
"Expiration": "Срок действия",
"Expired": "Истек",
"Expires": "Истекает",
"I've saved my API key": "Я сохранил мой API ключ",
"Last use": "Последнее использование",
"No API keys found": "API ключи не найдены",
"No expiration": "Не истекает",
"Revoke API key": "Отозвать API ключ",
"Revoked successfully": "Отозван успешно",
"Select expiration date": "Выберете срок действия",
"This action cannot be undone. Any applications using this API key will stop working.": "Это действие необратимо. Любые приложения, использующие этот API ключ, перестанут работать.",
"Update API key": "Обновить API ключ",
"Manage API keys for all users in the workspace": "Управлять API ключами для всех пользователей в рабочей области",
"AI settings": "Настройки ИИ",
"AI search": "Поиск ИИ",
"AI Answer": "Ответ ИИ",
"Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Sources": "Источники",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений",
"No answer available": "Ответ недоступен",
"Background color": "Цвет фона",
"Highlight color": "Цвет выделения",
"Remove color": "Удалить цвет"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "Видалити постачальника SSO", "Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?", "Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія", "Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}" "{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}",
"Icon": "Іконка",
"Upload image": "Завантажити зображення",
"Remove image": "Видалити зображення",
"Failed to remove image": "Не вдалося видалити зображення",
"Image exceeds 10MB limit.": "Зображення має займати менше, ніж 10 МБ.",
"Image removed successfully": "Зображення видалено",
"API key": "Ключ API",
"API key created successfully": "Ключ API успішно створено",
"API keys": "Ключі API",
"API management": "Управління API",
"Are you sure you want to revoke this API key": "Ви впевнені, що хочете відкликати цей ключ API",
"Create API Key": "Створити ключ API",
"Custom expiration date": "Користувацька дата закінчення",
"Enter a descriptive token name": "Введіть описову назву токена",
"Expiration": "Термін дії",
"Expired": "Закінчився",
"Expires": "Закінчується",
"I've saved my API key": "Я зберіг свій ключ API",
"Last use": "Останнє використання",
"No API keys found": "Ключі API не знайдено",
"No expiration": "Без терміну дії",
"Revoke API key": "Відкликати ключ API",
"Revoked successfully": "Успішно відкликано",
"Select expiration date": "Виберіть дату закінчення",
"This action cannot be undone. Any applications using this API key will stop working.": "Цю дію не можна скасувати. Будь-які додатки, що використовують цей ключ API, перестануть працювати.",
"Update API key": "Оновити ключ API",
"Manage API keys for all users in the workspace": "Керувати ключами API для всіх користувачів у робочій області",
"AI settings": "Налаштування ШІ",
"AI search": "Пошук з ШІ",
"AI Answer": "Відповідь ШІ",
"Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Sources": "Джерела",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень",
"No answer available": "Відповідь недоступна",
"Background color": "Колір фону",
"Highlight color": "Колір підсвічування",
"Remove color": "Видалити колір"
} }
@@ -527,5 +527,47 @@
"Delete SSO provider": "删除SSO提供商", "Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?", "Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗?",
"Action": "操作", "Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置" "{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置",
"Icon": "图标",
"Upload image": "上传图片",
"Remove image": "删除图片",
"Failed to remove image": "无法删除图片",
"Image exceeds 10MB limit.": "图片超过10MB限制。",
"Image removed successfully": "图片删除成功",
"API key": "API密钥",
"API key created successfully": "API密钥创建成功",
"API keys": "API密钥",
"API management": "API管理",
"Are you sure you want to revoke this API key": "确定要撤销此API密钥吗",
"Create API Key": "创建API密钥",
"Custom expiration date": "自定义到期日期",
"Enter a descriptive token name": "输入描述性令牌名称",
"Expiration": "到期",
"Expired": "已过期",
"Expires": "到期",
"I've saved my API key": "我已保存我的API密钥",
"Last use": "上次使用",
"No API keys found": "找不到API密钥",
"No expiration": "无到期",
"Revoke API key": "撤销API密钥",
"Revoked successfully": "撤销成功",
"Select expiration date": "选择到期日期",
"This action cannot be undone. Any applications using this API key will stop working.": "此操作无法撤销。使用此API密钥的任何应用程序将停止工作。",
"Update API key": "更新API密钥",
"Manage API keys for all users in the workspace": "管理工作空间中所有用户的API密钥",
"AI settings": "AI设置",
"AI search": "AI搜索",
"AI Answer": "AI回答",
"Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Sources": "来源",
"Ask AI not available for attachments": "附件不支持询问AI",
"No answer available": "无可用答案",
"Background color": "背景颜色",
"Highlight color": "突出显示颜色",
"Remove color": "移除颜色"
} }
+2
View File
@@ -37,6 +37,7 @@ import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx"; import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -107,6 +108,7 @@ export default function App() {
<Route path={"spaces"} element={<Spaces />} /> <Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} /> <Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>
@@ -12,13 +12,14 @@ import {
IconLock, IconLock,
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css"; import classes from "./settings.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index"; import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { import {
prefetchApiKeyManagement, prefetchApiKeyManagement,
@@ -109,6 +110,13 @@ const groupedData: DataGroup[] = [
isAdmin: true, isAdmin: true,
showDisabledInNonEE: true, showDisabledInNonEE: true,
}, },
{
label: "AI settings",
icon: IconSparkles,
path: "/settings/ai",
isAdmin: true,
isSelfhosted: true,
},
], ],
}, },
{ {
@@ -0,0 +1,113 @@
import React, { useMemo } from "react";
import { Paper, Text, Group, Stack, Loader, Box } from "@mantine/core";
import { IconSparkles, IconFileText } from "@tabler/icons-react";
import { Link } from "react-router-dom";
import { IAiSearchResponse } from "../services/ai-search-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
interface AiSearchResultProps {
result?: IAiSearchResponse;
isLoading?: boolean;
streamingAnswer?: string;
streamingSources?: any[];
}
export function AiSearchResult({
result,
isLoading,
streamingAnswer = "",
streamingSources = [],
}: AiSearchResultProps) {
const { t } = useTranslation();
// Use streaming data if available, otherwise fall back to result
const answer = streamingAnswer || result?.answer || "";
const sources =
streamingSources.length > 0 ? streamingSources : result?.sources || [];
// Deduplicate sources by pageId, keeping the one with highest similarity
const deduplicatedSources = useMemo(() => {
if (!sources || sources.length === 0) return [];
const pageMap = new Map();
sources.forEach((source) => {
const existing = pageMap.get(source.pageId);
if (!existing || source.similarity > existing.similarity) {
pageMap.set(source.pageId, source);
}
});
return Array.from(pageMap.values());
}, [sources]);
if (isLoading && !answer) {
return (
<Paper p="md" radius="md" withBorder>
<Group>
<Loader size="sm" />
<Text size="sm">{t("AI is thinking...")}</Text>
</Group>
</Paper>
);
}
if (!answer && !isLoading) {
return null;
}
return (
<Stack gap="md" p="md">
<Paper p="md" radius="md" withBorder>
<Group gap="xs" mb="sm">
<IconSparkles size={20} color="var(--mantine-color-blue-6)" />
<Text fw={600} size="sm">
{t("AI Answer")}
</Text>
{isLoading && <Loader size="xs" />}
</Group>
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(markdownToHtml(answer) as string),
}}
/>
</Paper>
{deduplicatedSources.length > 0 && (
<Stack gap="xs">
<Text size="xs" fw={600} c="dimmed">
{t("Sources")}
</Text>
{deduplicatedSources.map((source) => (
<Box
key={source.pageId}
component={Link}
to={buildPageUrl(source.spaceSlug, source.slugId, source.title)}
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<Paper
p="xs"
radius="sm"
withBorder
style={{ cursor: "pointer" }}
>
<Group gap="xs">
<IconFileText size={16} />
<Text size="sm" truncate>
{source.title}
</Text>
</Group>
</Paper>
</Box>
))}
</Stack>
)}
</Stack>
);
}
@@ -0,0 +1,69 @@
import { Group, Text, Switch, MantineSize, Title } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
export default function EnableAiSearch() {
const { t } = useTranslation();
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
)}
</Text>
</div>
<AiSearchToggle />
</Group>
</>
);
}
interface AiSearchToggleProps {
size?: MantineSize;
label?: string;
}
export function AiSearchToggle({ size, label }: AiSearchToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.search);
const { hasLicenseKey } = useLicense();
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ aiSearch: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle AI search")}
/>
);
}
@@ -0,0 +1,46 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore
interface UseAiSearchResult extends UseMutationResult<IAiSearchResponse, Error, IPageSearchParams> {
streamingAnswer: string;
streamingSources: any[];
clearStreaming: () => void;
}
export function useAiSearch(): UseAiSearchResult {
const [streamingAnswer, setStreamingAnswer] = useState("");
const [streamingSources, setStreamingSources] = useState<any[]>([]);
const clearStreaming = useCallback(() => {
setStreamingAnswer("");
setStreamingSources([]);
}, []);
const mutation = useMutation({
mutationFn: async (params: IPageSearchParams & { contentType?: string }) => {
setStreamingAnswer("");
setStreamingSources([]);
const { contentType, ...apiParams } = params;
return await askAi(apiParams, (chunk) => {
if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content);
}
if (chunk.sources) {
setStreamingSources(chunk.sources);
}
});
},
});
return {
...mutation,
streamingAnswer,
streamingSources,
clearStreaming,
};
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, useCallback, useRef } from "react";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
import { AiGenerateDto } from "@/ee/ai/types/ai.types.ts";
export function useAiStream() {
const [content, setContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const mutation = useAiGenerateStreamMutation();
const startStream = useCallback(
async (data: AiGenerateDto) => {
setContent("");
setIsStreaming(true);
try {
const controller = await mutation.mutateAsync({
...data,
onChunk: (chunk) => {
setContent((prev) => prev + chunk.content);
},
onError: (error) => {
console.error("AI stream error:", error);
setIsStreaming(false);
},
onComplete: () => {
setIsStreaming(false);
},
});
abortControllerRef.current = controller;
} catch (error) {
console.error("Failed to start stream:", error);
setIsStreaming(false);
}
},
[mutation]
);
const stopStream = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsStreaming(false);
}
}, []);
const resetContent = useCallback(() => {
setContent("");
}, []);
return {
content,
isStreaming,
startStream,
stopStream,
resetContent,
isLoading: mutation.isPending,
error: mutation.error,
};
}
@@ -0,0 +1,46 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import { Alert } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
export default function AiSettings() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
if (!isAdmin) {
return null;
}
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
return (
<>
<Helmet>
<title>AI - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("AI settings")} />
{!hasAccess && (
<Alert
icon={<IconInfoCircle />}
title={t("Enterprise feature")}
color="blue"
mb="lg"
>
{t(
"AI is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
)}
</Alert>
)}
<EnableAiSearch />
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import {
useMutation,
UseMutationResult,
useQuery,
UseQueryResult,
} from "@tanstack/react-query";
import {
generateAiContent,
generateAiContentStream,
} from "@/ee/ai/services/ai-service.ts";
import {
AiConfigResponse,
AiContentResponse,
AiGenerateDto,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export function useAiGenerateMutation(): UseMutationResult<
AiContentResponse,
Error,
AiGenerateDto
> {
return useMutation({
mutationFn: (data: AiGenerateDto) => generateAiContent(data),
});
}
interface StreamCallbacks {
onChunk: (chunk: AiStreamChunk) => void;
onError?: (error: AiStreamError) => void;
onComplete?: () => void;
}
export function useAiGenerateStreamMutation(): UseMutationResult<
AbortController,
Error,
AiGenerateDto & StreamCallbacks
> {
return useMutation({
mutationFn: ({ onChunk, onError, onComplete, ...data }) =>
generateAiContentStream(data, onChunk, onError, onComplete),
});
}
@@ -0,0 +1,83 @@
import api from "@/lib/api-client.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts";
export interface IAiSearchResponse {
answer: string;
sources?: Array<{
pageId: string;
title: string;
slugId: string;
spaceSlug: string;
similarity: number;
distance: number;
chunkIndex: number;
excerpt: string;
}>;
}
export async function askAi(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/ask", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let answer = "";
let sources: any[] = [];
let buffer = "";
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
// Keep the last incomplete line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.error) {
throw new Error(parsed.error);
}
if (parsed.content) {
answer += parsed.content;
onChunk?.({ content: parsed.content });
}
if (parsed.sources) {
sources = parsed.sources;
onChunk?.({ sources: parsed.sources });
}
} catch (e) {
if (e instanceof Error) {
throw e;
}
// Skip invalid JSON
}
}
}
}
}
return { answer, sources };
}
@@ -0,0 +1,89 @@
import api from "@/lib/api-client.ts";
import {
AiGenerateDto,
AiContentResponse,
AiStreamChunk,
AiStreamError,
} from "@/ee/ai/types/ai.types.ts";
export async function generateAiContent(
data: AiGenerateDto,
): Promise<AiContentResponse> {
const req = await api.post<AiContentResponse>("/ai/generate", data);
return req.data;
}
export async function generateAiContentStream(
data: AiGenerateDto,
onChunk: (chunk: AiStreamChunk) => void,
onError?: (error: AiStreamError) => void,
onComplete?: () => void,
): Promise<AbortController> {
const abortController = new AbortController();
try {
const response = await fetch("/api/ai/generate/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
signal: abortController.signal,
credentials: "include", // This ensures cookies are sent, matching axios withCredentials
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error("Response body is not readable");
}
const processStream = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
onComplete?.();
return;
}
try {
const parsed = JSON.parse(data);
if (parsed.error) {
onError?.(parsed);
} else {
onChunk(parsed);
}
} catch (e) {
// Ignore parse errors for incomplete chunks
}
}
}
}
} catch (error) {
if (error.name !== "AbortError") {
onError?.({ error: error.message });
}
} finally {
reader.releaseLock();
}
};
processStream();
} catch (error) {
onError?.({ error: error.message });
}
return abortController;
}
+40
View File
@@ -0,0 +1,40 @@
export enum AiAction {
IMPROVE_WRITING = "improve_writing",
FIX_SPELLING_GRAMMAR = "fix_spelling_grammar",
MAKE_SHORTER = "make_shorter",
MAKE_LONGER = "make_longer",
SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize",
CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate",
CUSTOM = "custom",
}
export interface AiGenerateDto {
action?: AiAction;
content: string;
prompt?: string;
}
export interface AiContentResponse {
content: string;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface AiConfigResponse {
configured: boolean;
availableActions: AiAction[];
}
export interface AiStreamChunk {
content: string;
}
export interface AiStreamError {
error: string;
}
@@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder withTableBorder
> >
<Table.Caption> <Table.Caption>
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com. To unlock enterprise features like AI, SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption> </Table.Caption>
<Table.Tbody> <Table.Tbody>
<Table.Tr> <Table.Tr>
@@ -144,16 +144,16 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => { onHide: () => {
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false); setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}, },
}, },
}; };
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false); const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
return ( return (
<BubbleMenu {...bubbleMenuProps}> <BubbleMenu {...bubbleMenuProps}>
@@ -164,8 +164,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false); setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false); setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}} }}
/> />
@@ -175,8 +175,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => { setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false); setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false); setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}} }}
/> />
@@ -1,5 +1,5 @@
import { Dispatch, FC, SetStateAction } from "react"; import React, { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconPalette } from "@tabler/icons-react"; import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react";
import { import {
ActionIcon, ActionIcon,
Button, Button,
@@ -8,6 +8,9 @@ import {
ScrollArea, ScrollArea,
Text, Text,
Tooltip, Tooltip,
SimpleGrid,
Box,
Stack,
} from "@mantine/core"; } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
@@ -61,9 +64,12 @@ const TEXT_COLORS: BubbleColorMenuItem[] = [
name: "Gray", name: "Gray",
color: "#A8A29E", color: "#A8A29E",
}, },
{
name: "Brown",
color: "#92400E",
},
]; ];
// TODO: handle dark mode
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{ {
name: "Default", name: "Default",
@@ -71,35 +77,39 @@ const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
}, },
{ {
name: "Blue", name: "Blue",
color: "#c1ecf9", color: "#98d8f2",
}, },
{ {
name: "Green", name: "Green",
color: "#acf79f", color: "#7edb6c",
}, },
{ {
name: "Purple", name: "Purple",
color: "#f6f3f8", color: "#e0d6ed",
}, },
{ {
name: "Red", name: "Red",
color: "#fdebeb", color: "#ffc6c2",
}, },
{ {
name: "Yellow", name: "Yellow",
color: "#fbf4a2", color: "#faf594",
}, },
{ {
name: "Orange", name: "Orange",
color: "#faebdd", color: "#f5c8a9",
}, },
{ {
name: "Pink", name: "Pink",
color: "#faf1f5", color: "#f5cfe0",
}, },
{ {
name: "Gray", name: "Gray",
color: "#f1f1ef", color: "#dfdfd7",
},
{
name: "Brown",
color: "#d7c4b7",
}, },
]; ];
@@ -112,17 +122,21 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
selector: ctx => { selector: (ctx) => {
if (!ctx.editor) { if (!ctx.editor) {
return null; return null;
} }
const activeColors: Record<string, boolean> = {}; const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => { TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color }); activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", {
color,
});
}); });
HIGHLIGHT_COLORS.forEach(({ color }) => { HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color }); activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", {
color,
});
}); });
return activeColors; return activeColors;
@@ -133,67 +147,152 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
return null; return null;
} }
const activeColorItem = TEXT_COLORS.find(({ color }) => const activeColorItem = TEXT_COLORS.find(
editorState[`text_${color}`] ({ color }) => editorState[`text_${color}`],
); );
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => const activeHighlightItem = HIGHLIGHT_COLORS.find(
editorState[`highlight_${color}`] ({ color }) => editorState[`highlight_${color}`],
); );
return ( return (
<Popover width={200} opened={isOpen} withArrow> <Popover width={220} opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Tooltip label={t("Text color")} withArrow> <Tooltip label={t("Text color")} withArrow>
<ActionIcon <Button
variant="default" variant="default"
size="lg"
radius="0" radius="0"
style={{ rightSection={<IconChevronDown size={16} />}
border: "none",
color: activeColorItem?.color,
}}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""}
className="color-selector-trigger"
style={{
height: "34px",
border: "none",
fontWeight: 500,
fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}}
> >
<IconPalette size={16} stroke={2} /> A
</ActionIcon> </Button>
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
{/* make mah responsive */}
<ScrollArea.Autosize type="scroll" mah="400"> <ScrollArea.Autosize type="scroll" mah="400">
<Text span c="dimmed" tt="uppercase" inherit> <Stack gap="md">
{t("Color")} <Box>
<Text size="sm" fw={600} mb="xs">
{t("Text color")}
</Text> </Text>
<SimpleGrid cols={5} spacing="xs">
<Button.Group orientation="vertical">
{TEXT_COLORS.map(({ name, color }, index) => ( {TEXT_COLORS.map(({ name, color }, index) => (
<Button <Tooltip key={index} label={t(name)} withArrow>
key={index} <Box
variant="default"
leftSection={<span style={{ color }}>A</span>}
justify="left"
fullWidth
rightSection={
editorState[`text_${color}`] && (
<IconCheck style={{ width: rem(16) }} />
)
}
onClick={() => { onClick={() => {
if (name === "Default") { if (name === "Default") {
editor.commands.unsetColor(); editor.commands.unsetColor();
} else { } else {
editor.chain().focus().setColor(color || "").run(); editor
.chain()
.focus()
.setColor(color || "")
.run();
} }
setIsOpen(false); setIsOpen(false);
}} }}
style={{ border: "none" }} style={{
width: rem(28),
height: rem(28),
borderRadius: rem(6),
border: editorState[`text_${color}`]
? "2px solid var(--mantine-color-gray-8)"
: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: color || "var(--mantine-color-gray-8)",
}}
> >
{t(name)} A
</Button> </Box>
</Tooltip>
))} ))}
</Button.Group> </SimpleGrid>
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
<Tooltip key={index} label={t(name)} withArrow>
<Box
onClick={() => {
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
}}
style={{
width: rem(28),
height: rem(28),
borderRadius: rem(4),
backgroundColor: color || "var(--mantine-color-gray-2)",
border: "1px solid var(--mantine-color-gray-4)",
cursor: "pointer",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: rem(16),
fontWeight: 600,
color: "var(--mantine-color-gray-8)",
}}
>
{editorState[`highlight_${color}`] ? (
<IconCheck
size={16}
color="var(--mantine-color-green-7)"
/>
) : (
"A"
)}
</Box>
</Tooltip>
))}
</SimpleGrid>
</Box>
<Button
variant="default"
fullWidth
onClick={() => {
editor.commands.unsetColor();
editor.commands.unsetHighlight();
setIsOpen(false);
}}
>
{t("Remove color")}
</Button>
</Stack>
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css"; import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useComputedColorScheme } from "@mantine/core"; import { useComputedColorScheme } from "@mantine/core";
import DOMPurify from "dompurify";
interface MermaidViewProps { interface MermaidViewProps {
props: NodeViewProps; props: NodeViewProps;
@@ -37,7 +38,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
.catch((err) => { .catch((err) => {
if (props.editor.isEditable) { if (props.editor.isEditable) {
setPreview( setPreview(
`<div class="${classes.error}">${t("Mermaid diagram error:")} ${err}</div>`, `<div class="${classes.error}">${t("Mermaid diagram error:")} ${DOMPurify.sanitize(err)}</div>`,
); );
} else { } else {
setPreview( setPreview(
@@ -34,7 +34,9 @@ export const handlePaste = (
return false; return false;
} }
createMentionAction(url, view, pos, creatorId); const anchorId = match[6] ? match[6].split('#')[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
return true; return true;
} }
@@ -87,7 +87,7 @@ export default function DrawioView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
@@ -85,7 +85,7 @@ export default function EmbedView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
{embedUrl ? ( {embedUrl ? (
<ResizableWrapper <ResizableWrapper
initialHeight={nodeHeight || 480} initialHeight={nodeHeight || 480}
@@ -118,7 +118,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
}; };
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<ReactClearModal <ReactClearModal
style={{ style={{
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
@@ -16,7 +16,7 @@ export default function ImageView(props: NodeViewProps) {
}, [align]); }, [align]);
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<Image <Image
radius="md" radius="md"
fit="contain" fit="contain"
@@ -9,6 +9,7 @@ export type LinkFn = (
view: EditorView, view: EditorView,
pos: number, pos: number,
creatorId: string, creatorId: string,
anchorId?: string,
) => void; ) => void;
export interface InternalLinkOptions { export interface InternalLinkOptions {
@@ -18,7 +19,7 @@ export interface InternalLinkOptions {
export const handleInternalLink = export const handleInternalLink =
({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn => ({ validateFn, onResolveLink }: InternalLinkOptions): LinkFn =>
async (url: string, view, pos, creatorId) => { async (url: string, view, pos, creatorId, anchorId) => {
const validated = validateFn(url, view); const validated = validateFn(url, view);
if (!validated) return; if (!validated) return;
@@ -35,6 +36,7 @@ export const handleInternalLink =
entityId: page.id, entityId: page.id,
slugId: page.slugId, slugId: page.slugId,
creatorId: creatorId, creatorId: creatorId,
anchorId: anchorId,
}); });
if (!node) return; if (!node) return;
@@ -11,7 +11,7 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const { const {
@@ -27,10 +27,11 @@ export default function MentionView(props: NodeViewProps) {
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: label,
anchorId,
}); });
return ( return (
<NodeViewWrapper style={{ display: "inline" }}> <NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
{entityType === "user" && ( {entityType === "user" && (
<Text className={classes.userMention} component="span"> <Text className={classes.userMention} component="span">
@{label} @{label}
@@ -42,7 +43,7 @@ export default function MentionView(props: NodeViewProps) {
component={Link} component={Link}
fw={500} fw={500}
to={ to={
isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label) isShareRoute ? shareSlugUrl : buildPageUrl(spaceSlug, slugId, label, anchorId)
} }
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -20,7 +20,6 @@ import {
IconCalendar, IconCalendar,
IconAppWindow, IconAppWindow,
IconSitemap, IconSitemap,
IconAlignLeft2,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -154,14 +153,6 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(), editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
}, },
{
title: "Table of contents",
description: "Insert table of contents",
searchTerms: ["toc"],
icon: IconAlignLeft2,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).insertTableOfContents().run(),
},
{ {
title: "Image", title: "Image",
description: "Upload any image from your device.", description: "Upload any image from your device.",
@@ -52,7 +52,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (error && !shareId) { if (error && !shareId) {
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")} {t("Failed to load subpages")}
</Text> </Text>
@@ -62,7 +62,7 @@ export default function SubpagesView(props: NodeViewProps) {
if (subpages.length === 0) { if (subpages.length === 0) {
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<div className={classes.container}> <div className={classes.container}>
<Text c="dimmed" size="md" py="md"> <Text c="dimmed" size="md" py="md">
{t("No subpages")} {t("No subpages")}
@@ -73,7 +73,7 @@ export default function SubpagesView(props: NodeViewProps) {
} }
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<div className={classes.container}> <div className={classes.container}>
<Stack gap={5}> <Stack gap={5}>
{subpages.map((page) => ( {subpages.map((page) => (
@@ -1,61 +0,0 @@
.header {
}
.container {
}
.emptyState {
}
.link {
outline: none;
cursor: pointer;
display: block;
width: 100%;
text-align: start;
word-wrap: break-word;
background-color: transparent;
color: var(--mantine-color-text);
font-size: var(--mantine-font-size-sm);
line-height: var(--mantine-line-height-sm);
padding: 6px;
border-top-right-radius: var(--mantine-radius-sm);
border-bottom-right-radius: var(--mantine-radius-sm);
border: none;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-2),
var(--mantine-color-dark-6)
);
}
@media (max-width: $mantine-breakpoint-sm) {
& {
border: none !important;
padding-left: 0px;
}
}
}
.linkActive {
font-weight: 500;
border-left-color: light-dark(
var(--mantine-color-grey-5),
var(--mantine-color-grey-3)
);
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
&,
&:hover {
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-5)
) !important;
}
}
.leftBorder {
border-left: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
@@ -1,55 +0,0 @@
import { Editor as CoreEditor } from "@tiptap/core";
import { TableOfContentsStorage } from "@tiptap/extension-table-of-contents";
import { NodeViewWrapper, useEditorState } from "@tiptap/react";
import { memo } from "react";
import { clsx } from "clsx";
import classes from "./table-of-contents-nodeview.module.css";
export type TableOfContentsProps = {
editor: CoreEditor;
onItemClick?: () => void;
};
export const TableOfContentsNodeview = memo(
({ editor, onItemClick }: TableOfContentsProps) => {
const content = useEditorState({
editor,
selector: (ctx) =>
(ctx.editor.storage.tableOfContents as TableOfContentsStorage)?.content,
});
return (
<NodeViewWrapper>
<div contentEditable={false}>
<div className={classes.header}>Table of contents</div>
{content.length > 0 ? (
<div className={classes.container}>
{content
.filter((item) => item.level <= 3)
.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
style={{ marginLeft: `${1 * item.level - 1}rem` }}
onClick={onItemClick}
className={clsx(
classes.link,
item.isActive && classes.linkActive,
)}
>
{item.itemIndex}. {item.textContent}
</a>
))}
</div>
) : (
<div className={classes.emptyState}>
Start adding headlines to your document
</div>
)}
</div>
</NodeViewWrapper>
);
},
);
TableOfContentsNodeview.displayName = "TableOfContentsNodeview";
@@ -15,7 +15,7 @@ export default function VideoView(props: NodeViewProps) {
}, [align]); }, [align]);
return ( return (
<NodeViewWrapper> <NodeViewWrapper data-drag-handle>
<video <video
preload="metadata" preload="metadata"
width={width} width={width}
@@ -1,18 +1,20 @@
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { TextAlign } from "@tiptap/extension-text-align"; import { TextAlign } from "@tiptap/extension-text-align";
import { CharacterCount } from "@tiptap/extension-character-count";
import { TaskList } from "@tiptap/extension-task-list"; import { TaskList } from "@tiptap/extension-task-list";
import { ListKeymap } from "@tiptap/extension-list-keymap";
import { TaskItem } from "@tiptap/extension-task-item"; import { TaskItem } from "@tiptap/extension-task-item";
import { Underline } from "@tiptap/extension-underline"; import { Underline } from "@tiptap/extension-underline";
import { Superscript } from "@tiptap/extension-superscript"; import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript"; import SubScript from "@tiptap/extension-subscript";
import { Highlight } from "@tiptap/extension-highlight";
import { Typography } from "@tiptap/extension-typography"; import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style"; import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import SlashCommand from "@/features/editor/extensions/slash-command"; import SlashCommand from "@/features/editor/extensions/slash-command";
import { TableOfContents as TiptapTableOfContents } from "@tiptap/extension-table-of-contents"; import { Collaboration, isChangeOrigin } from "@tiptap/extension-collaboration";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
import { HocuspocusProvider } from "@hocuspocus/provider"; import { HocuspocusProvider } from "@hocuspocus/provider";
import { import {
@@ -41,7 +43,9 @@ import {
Mention, Mention,
Subpages, Subpages,
TableDndExtension, TableDndExtension,
TableOfContentsNode, Heading,
Highlight,
UniqueID,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -50,11 +54,8 @@ import {
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import MathInlineView from "@/features/editor/components/math/math-inline.tsx"; import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx"; import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Youtube } from "@tiptap/extension-youtube";
import ImageView from "@/features/editor/components/image/image-view.tsx"; import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
@@ -62,6 +63,7 @@ import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell"; import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap"; import abap from "highlightjs-sap-abap";
@@ -78,9 +80,7 @@ import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import i18n from "@/i18n.ts"; import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command"; import EmojiCommand from "./emoji-command";
import { CharacterCount } from "@tiptap/extension-character-count";
import { countWords } from "alfaaz"; import { countWords } from "alfaaz";
import { TableOfContentsNodeview } from "@/features/editor/components/table-of-contents/table-of-contents-nodeview.tsx";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); lowlight.register("mermaid", plaintext);
@@ -96,6 +96,7 @@ lowlight.register("scala", scala);
export const mainExtensions = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({
heading: false,
history: false, history: false,
dropcursor: { dropcursor: {
width: 3, width: 3,
@@ -108,6 +109,11 @@ export const mainExtensions = [
}, },
}, },
}), }),
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
filterTransaction: (transaction) => !isChangeOrigin(transaction),
}),
Placeholder.configure({ Placeholder.configure({
placeholder: ({ node }) => { placeholder: ({ node }) => {
if (node.type.name === "heading") { if (node.type.name === "heading") {
@@ -128,6 +134,7 @@ export const mainExtensions = [
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
ListKeymap,
Underline, Underline,
LinkExtension.configure({ LinkExtension.configure({
openOnClick: false, openOnClick: false,
@@ -244,10 +251,6 @@ export const mainExtensions = [
}; };
}, },
}).configure(), }).configure(),
TiptapTableOfContents,
TableOfContentsNode.configure({
view: TableOfContentsNodeview,
}),
] as any; ] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -0,0 +1,58 @@
import { Editor } from "@tiptap/react";
import { useCallback, useEffect, useState } from "react";
function waitForState(checkFn: () => boolean): Promise<void> {
return new Promise((resolve) => {
const interval = setInterval(() => {
if (checkFn()) {
clearInterval(interval);
resolve();
}
}, 800);
});
}
export const useEditorScroll = ({
canScroll,
initialScrollTo,
}: {
canScroll: () => boolean;
initialScrollTo?: string;
}) => {
const [scrollTo, setScrollTo] = useState<string>(initialScrollTo || "");
useEffect(() => {
if (!initialScrollTo) {
setScrollTo(window.location.hash ? window.location.hash.slice(1) : "");
}
}, [initialScrollTo]);
const handleScrollTo = useCallback(async (editor: Editor, _scrollTo: string | null = null, tryCount: number = 0) => {
await waitForState(() => canScroll());
return new Promise((resolve) => {
const MAX_TRY_COUNT = 10;
if (tryCount >= MAX_TRY_COUNT) {
resolve(false);
return;
}
const targetId = _scrollTo || scrollTo;
if (!targetId) {
resolve(false);
return;
}
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`);
if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true);
} else {
setTimeout(async () => {
resolve(await handleScrollTo(editor, targetId, tryCount + 1));
}, 200);
}
});
}, [scrollTo, canScroll]);
return { scrollTo, handleScrollTo };
};
@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs"; import * as Y from "yjs";
import { import {
@@ -56,6 +56,7 @@ import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -68,7 +69,16 @@ export default function PageEditor({
editable, editable,
content, content,
}: PageEditorProps) { }: PageEditorProps) {
const collaborationURL = useCollaborationUrl(); const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
useEffect(() => {
isComponentMounted.current = true;
}, []);
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setEditor] = useAtom(pageEditorAtom); const [, setEditor] = useAtom(pageEditorAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
@@ -95,6 +105,8 @@ export default function PageEditor({
const userPageEditMode = const userPageEditMode =
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(() => isComponentMounted.current && editorCreated.current, [isComponentMounted, editorCreated]);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
const providersRef = useRef<{ const providersRef = useRef<{
local: IndexeddbPersistence; local: IndexeddbPersistence;
@@ -264,6 +276,8 @@ export default function PageEditor({
// @ts-ignore // @ts-ignore
setEditor(editor); setEditor(editor);
editor.storage.pageId = pageId; editor.storage.pageId = pageId;
handleScrollTo(editor);
editorCreated.current = true;
} }
}, },
onUpdate({ editor }) { onUpdate({ editor }) {
@@ -1,13 +1,14 @@
import "@/features/editor/styles/index.css"; import "@/features/editor/styles/index.css";
import React, { useMemo } from "react"; import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { EditorProvider } from "@tiptap/react"; import { EditorProvider } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions"; import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Document } from "@tiptap/extension-document"; import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading"; import { Heading, generateNodeId, UniqueID } from "@docmost/editor-ext";
import { Text } from "@tiptap/extension-text"; import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts"; import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
interface PageEditorProps { interface PageEditorProps {
title: string; title: string;
@@ -21,9 +22,34 @@ export default function ReadonlyPageEditor({
pageId, pageId,
}: PageEditorProps) { }: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom); const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
const isComponentMounted = useRef(false);
const editorCreated = useRef(false);
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
[isComponentMounted, editorCreated],
);
const initialScrollTo = window.location.hash
? window.location.hash.slice(1)
: "";
const { handleScrollTo } = useEditorScroll({ canScroll, initialScrollTo });
useEffect(() => {
isComponentMounted.current = true;
}, []);
const extensions = useMemo(() => { const extensions = useMemo(() => {
return [...mainExtensions]; const filteredExtensions = mainExtensions.filter(
(ext) => ext.name !== "uniqueID",
);
return [
...filteredExtensions,
UniqueID.configure({
types: ["heading", "paragraph"],
updateDocument: false,
}),
];
}, []); }, []);
const titleExtensions = [ const titleExtensions = [
@@ -59,6 +85,9 @@ export default function ReadonlyPageEditor({
} }
// @ts-ignore // @ts-ignore
setReadOnlyEditor(editor); setReadOnlyEditor(editor);
handleScrollTo(editor);
editorCreated.current = true;
} }
}} }}
></EditorProvider> ></EditorProvider>
@@ -5,7 +5,7 @@
); );
color: light-dark( color: light-dark(
var(--mantine-color-default-color), var(--mantine-color-default-color),
var(--mantine-color-dark-0) var(--mantine-color-white)
); );
font-size: var(--mantine-font-size-md); font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-xl); line-height: var(--mantine-line-height-xl);
@@ -141,7 +141,7 @@
.selection, .selection,
*::selection { *::selection {
background-color: Highlight; background-color: light-dark(Highlight, var(--mantine-color-gray-7));
} }
.comment-mark { .comment-mark {
@@ -186,6 +186,39 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
}
.ProseMirror > h1,
.ProseMirror > h2,
.ProseMirror > h3,
.ProseMirror > h4,
.ProseMirror > h5,
.ProseMirror > h6 {
> .link-btn {
cursor: pointer;
position: relative;
}
> .link-btn > .link-btn-content {
opacity: 0;
position: absolute;
left: 5px;
top: 0;
height: 100%;
transition: opacity 0.15s ease;
display: inline-flex;
justify-content: center;
flex-direction: column;
}
&:hover > .link-btn > .link-btn-content {
opacity: 1;
}
scroll-margin-top: 80px; /* match your header height */
} }
.ProseMirror-icon { .ProseMirror-icon {
@@ -209,4 +242,3 @@
.actionIconGroup { .actionIconGroup {
background: var(--mantine-color-body); background: var(--mantine-color-body);
} }
@@ -0,0 +1,177 @@
/* Highlight colors with dark mode support */
.ProseMirror {
/* Blue */
mark[data-color="#98d8f2"] {
background-color: light-dark(
rgb(224 242 254),
rgba(37, 99, 235, 0.35)
) !important;
}
/* Green */
mark[data-color="#7edb6c"] {
background-color: light-dark(
rgb(220 252 231),
rgba(0, 138, 0, 0.35)
) !important;
}
/* Purple */
mark[data-color="#e0d6ed"] {
background-color: light-dark(
rgb(243 232 255),
rgba(147, 51, 234, 0.35)
) !important;
}
/* Red */
mark[data-color="#ffc6c2"] {
background-color: light-dark(
rgb(255 228 230),
rgba(224, 0, 0, 0.35)
) !important;
}
/* Yellow */
mark[data-color="#faf594"] {
background-color: light-dark(
rgb(254 249 195),
rgba(234, 179, 8, 0.35)
) !important;
}
/* Orange */
mark[data-color="#f5c8a9"] {
background-color: light-dark(
rgb(251, 236, 221),
rgba(255, 165, 0, 0.45)
) !important;
}
/* Pink */
mark[data-color="#f5cfe0"] {
background-color: light-dark(
rgb(252, 241, 246),
rgba(186, 64, 129, 0.35)
) !important;
}
/* Gray */
mark[data-color="#dfdfd7"] {
background-color: light-dark(
rgb(238 238 235),
rgba(168, 162, 158, 0.35)
) !important;
}
/* Brown */
mark[data-color="#d7c4b7"] {
background-color: light-dark(
rgb(215 196 183),
rgba(146, 64, 14, 0.35)
) !important;
}
}
/* Color selector trigger button styles */
.color-selector-trigger[data-text-color="#2563EB"] {
color: #2563EB !important;
}
.color-selector-trigger[data-text-color="#008A00"] {
color: #008A00 !important;
}
.color-selector-trigger[data-text-color="#9333EA"] {
color: #9333EA !important;
}
.color-selector-trigger[data-text-color="#E00000"] {
color: #E00000 !important;
}
.color-selector-trigger[data-text-color="#EAB308"] {
color: #EAB308 !important;
}
.color-selector-trigger[data-text-color="#FFA500"] {
color: #FFA500 !important;
}
.color-selector-trigger[data-text-color="#BA4081"] {
color: #BA4081 !important;
}
.color-selector-trigger[data-text-color="#A8A29E"] {
color: #A8A29E !important;
}
.color-selector-trigger[data-text-color="#92400E"] {
color: #92400E !important;
}
/* Highlight background colors with light-dark support - solid colors for trigger button */
.color-selector-trigger[data-highlight-color="#98d8f2"] {
background-color: light-dark(
rgb(224 242 254),
rgb(30 64 175)
) !important;
}
.color-selector-trigger[data-highlight-color="#7edb6c"] {
background-color: light-dark(
rgb(220 252 231),
rgb(21 128 61)
) !important;
}
.color-selector-trigger[data-highlight-color="#e0d6ed"] {
background-color: light-dark(
rgb(243 232 255),
rgb(107 33 168)
) !important;
}
.color-selector-trigger[data-highlight-color="#ffc6c2"] {
background-color: light-dark(
rgb(255 228 230),
rgb(185 28 28)
) !important;
}
.color-selector-trigger[data-highlight-color="#faf594"] {
background-color: light-dark(
rgb(254 249 195),
rgb(161 98 7)
) !important;
}
.color-selector-trigger[data-highlight-color="#f5c8a9"] {
background-color: light-dark(
rgb(251 236 221),
rgb(194 65 12)
) !important;
}
.color-selector-trigger[data-highlight-color="#f5cfe0"] {
background-color: light-dark(
rgb(252 241 246),
rgb(157 23 77)
) !important;
}
.color-selector-trigger[data-highlight-color="#dfdfd7"] {
background-color: light-dark(
rgb(238 238 235),
rgb(115 115 115)
) !important;
}
.color-selector-trigger[data-highlight-color="#d7c4b7"] {
background-color: light-dark(
rgb(215 196 183),
rgb(120 53 15)
) !important;
}
@@ -12,3 +12,4 @@
@import "./find.css"; @import "./find.css";
@import "./mention.css"; @import "./mention.css";
@import "./ordered-list.css"; @import "./ordered-list.css";
@import "./highlight.css";
@@ -104,7 +104,10 @@ export function TitleEditor({
}); });
useEffect(() => { useEffect(() => {
const pageSlug = buildPageUrl(spaceSlug, slugId, title); const anchorId = window.location.hash
? window.location.hash.substring(1)
: undefined;
const pageSlug = buildPageUrl(spaceSlug, slugId, title, anchorId);
navigate(pageSlug, { replace: true }); navigate(pageSlug, { replace: true });
}, [title]); }, [title]);
@@ -192,10 +195,43 @@ export function TitleEditor({
const { key } = event; const { key } = event;
const { $head } = titleEditor.state.selection; const { $head } = titleEditor.state.selection;
if (key === "Enter") {
event.preventDefault();
const { $from } = titleEditor.state.selection;
const titleText = titleEditor.getText();
// Get the text offset within the heading node (not document position)
const textOffset = $from.parentOffset;
const textAfterCursor = titleText.slice(textOffset);
// Delete text after cursor from title (this will be in undo history)
const endPos = titleEditor.state.doc.content.size;
if (textAfterCursor) {
titleEditor.commands.deleteRange({ from: $from.pos, to: endPos });
}
// Don't add to history so undo in page editor won't remove this split
pageEditor
.chain()
.command(({ tr }) => {
tr.setMeta("addToHistory", false);
return true;
})
.insertContentAt(0, {
type: "paragraph",
content: textAfterCursor
? [{ type: "text", text: textAfterCursor }]
: undefined,
})
.focus("start")
.run();
return;
}
const shouldFocusEditor = const shouldFocusEditor =
key === "Enter" || key === "ArrowDown" || (key === "ArrowRight" && !$head.nodeAfter);
key === "ArrowDown" ||
(key === "ArrowRight" && !$head.nodeAfter);
if (shouldFocusEditor) { if (shouldFocusEditor) {
pageEditor.commands.focus("start"); pageEditor.commands.focus("start");
+13 -6
View File
@@ -15,22 +15,29 @@ export const buildPageUrl = (
spaceName: string, spaceName: string,
pageSlugId: string, pageSlugId: string,
pageTitle?: string, pageTitle?: string,
anchorId?: string,
): string => { ): string => {
let url: string;
if (spaceName === undefined) { if (spaceName === undefined) {
return `/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return `/s/${spaceName}/p/${buildPageSlug(pageSlugId, pageTitle)}`; return anchorId ? `${url}#${anchorId}` : url;
}; };
export const buildSharedPageUrl = (opts: { export const buildSharedPageUrl = (opts: {
shareId: string; shareId: string;
pageSlugId: string; pageSlugId: string;
pageTitle?: string; pageTitle?: string;
anchorId?: string;
}): string => { }): string => {
const { shareId, pageSlugId, pageTitle } = opts; const { shareId, pageSlugId, pageTitle, anchorId } = opts;
let url: string;
if (!shareId) { if (!shareId) {
return `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`; url = `/share/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} else {
url = `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
} }
return anchorId ? `${url}#${anchorId}` : url;
return `/share/${shareId}/p/${buildPageSlug(pageSlugId, pageTitle)}`;
}; };
@@ -9,6 +9,7 @@ import {
ScrollArea, ScrollArea,
Avatar, Avatar,
Group, Group,
Switch,
getDefaultZIndex, getDefaultZIndex,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@@ -17,6 +18,7 @@ import {
IconFileDescription, IconFileDescription,
IconSearch, IconSearch,
IconCheck, IconCheck,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
@@ -24,15 +26,21 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query";
import { useLicense } from "@/ee/hooks/use-license"; import { useLicense } from "@/ee/hooks/use-license";
import classes from "./search-spotlight-filters.module.css"; import classes from "./search-spotlight-filters.module.css";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
interface SearchSpotlightFiltersProps { interface SearchSpotlightFiltersProps {
onFiltersChange?: (filters: any) => void; onFiltersChange?: (filters: any) => void;
onAskClick?: () => void;
spaceId?: string; spaceId?: string;
isAiMode?: boolean;
} }
export function SearchSpotlightFilters({ export function SearchSpotlightFilters({
onFiltersChange, onFiltersChange,
onAskClick,
spaceId, spaceId,
isAiMode = false,
}: SearchSpotlightFiltersProps) { }: SearchSpotlightFiltersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
@@ -42,6 +50,7 @@ export function SearchSpotlightFilters({
const [spaceSearchQuery, setSpaceSearchQuery] = useState(""); const [spaceSearchQuery, setSpaceSearchQuery] = useState("");
const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300); const [debouncedSpaceQuery] = useDebouncedValue(spaceSearchQuery, 300);
const [contentType, setContentType] = useState<string | null>("page"); const [contentType, setContentType] = useState<string | null>("page");
const [workspace] = useAtom(workspaceAtom);
const { data: spacesData } = useGetSpacesQuery({ const { data: spacesData } = useGetSpacesQuery({
page: 1, page: 1,
@@ -120,6 +129,31 @@ export function SearchSpotlightFilters({
return ( return (
<div className={classes.filtersContainer}> <div className={classes.filtersContainer}>
{workspace?.settings?.ai?.search === true && (
<div
style={{
display: "flex",
alignItems: "center",
height: "32px",
paddingLeft: "8px",
paddingRight: "8px",
}}
>
<Switch
checked={isAiMode}
onChange={(event) => onAskClick()}
label={t("Ask AI")}
size="sm"
color="blue"
labelPosition="left"
styles={{
root: { display: "flex", alignItems: "center" },
label: { paddingRight: "8px", fontSize: "13px", fontWeight: 500 },
}}
/>
</div>
)}
<Menu <Menu
shadow="md" shadow="md"
width={250} width={250}
@@ -231,7 +265,7 @@ export function SearchSpotlightFilters({
contentType !== option.value && contentType !== option.value &&
handleFilterChange("contentType", option.value) handleFilterChange("contentType", option.value)
} }
disabled={option.disabled} disabled={option.disabled || (isAiMode && option.value === "attachment")}
> >
<Group flex="1" gap="xs"> <Group flex="1" gap="xs">
<div> <div>
@@ -241,6 +275,11 @@ export function SearchSpotlightFilters({
{t("Enterprise")} {t("Enterprise")}
</Badge> </Badge>
)} )}
{!option.disabled && isAiMode && option.value === "attachment" && (
<Text size="xs" mt={4}>
{t("Ask AI not available for attachments")}
</Text>
)}
</div> </div>
{contentType === option.value && <IconCheck size={20} />} {contentType === option.value && <IconCheck size={20} />}
</Group> </Group>
@@ -1,12 +1,16 @@
import { Spotlight } from "@mantine/spotlight"; import { Spotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch, IconSparkles } from "@tabler/icons-react";
import React, { useState, useMemo } from "react"; import { Group, Button } from "@mantine/core";
import React, { useState, useMemo, useEffect } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import { searchSpotlightStore } from "../constants.ts"; import { searchSpotlightStore } from "../constants.ts";
import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx"; import { SearchSpotlightFilters } from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts"; import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { useAiSearch } from "../../../ee/ai/hooks/use-ai-search.ts";
import { SearchResultItem } from "./search-result-item.tsx"; import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useLicense } from "@/ee/hooks/use-license.tsx"; import { useLicense } from "@/ee/hooks/use-license.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
@@ -24,6 +28,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}>({ }>({
contentType: "page", contentType: "page",
}); });
const [isAiMode, setIsAiMode] = useState(false);
// Build unified search params // Build unified search params
const searchParams = useMemo(() => { const searchParams = useMemo(() => {
@@ -40,7 +45,42 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
return params; return params;
}, [debouncedSearchQuery, filters]); }, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams); const { data: searchResults, isLoading } = useUnifiedSearch(
searchParams,
!isAiMode // Disable regular search when in AI mode
);
const {
//@ts-ignore
data: aiSearchResult,
//@ts-ignore
isPending: isAiLoading,
//@ts-ignore
mutate: triggerAiSearchMutation,
//@ts-ignore
reset: resetAiMutation,
//@ts-ignore
error: aiSearchError,
streamingAnswer,
streamingSources,
clearStreaming,
} = useAiSearch();
// Clear streaming state and mutation data when query changes (user is typing a new query)
useEffect(() => {
clearStreaming();
resetAiMutation();
}, [query, clearStreaming, resetAiMutation]);
// Show error notification when AI search fails
useEffect(() => {
if (aiSearchError) {
notifications.show({
message: aiSearchError.message || t("AI search failed. Please try again."),
color: "red",
position: "top-center"
});
}
}, [aiSearchError, t]);
// Determine result type for rendering // Determine result type for rendering
const isAttachmentSearch = const isAttachmentSearch =
@@ -59,6 +99,16 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
setFilters(newFilters); setFilters(newFilters);
}; };
const handleAskClick = () => {
setIsAiMode(!isAiMode);
};
const handleAiSearchTrigger = () => {
if (query.trim() && isAiMode) {
triggerAiSearchMutation(searchParams);
}
};
return ( return (
<> <>
<Spotlight.Root <Spotlight.Root
@@ -72,10 +122,30 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
backgroundOpacity: 0.55, backgroundOpacity: 0.55,
}} }}
> >
<Group gap="xs" px="sm" pt="sm" pb="xs">
<Spotlight.Search <Spotlight.Search
placeholder={t("Search...")} placeholder={isAiMode ? t("Ask a question...") : t("Search...")}
leftSection={<IconSearch size={20} stroke={1.5} />} leftSection={<IconSearch size={20} stroke={1.5} />}
style={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === "Enter" && isAiMode && query.trim() && !isAiLoading) {
e.preventDefault();
handleAiSearchTrigger();
}
}}
/> />
{isAiMode && hasLicenseKey && (
<Button
size="xs"
leftSection={<IconSparkles size={16} />}
onClick={handleAiSearchTrigger}
disabled={!query.trim()}
loading={isAiLoading}
>
Ask
</Button>
)}
</Group>
<div <div
style={{ style={{
@@ -84,11 +154,32 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
> >
<SearchSpotlightFilters <SearchSpotlightFilters
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
onAskClick={handleAskClick}
spaceId={spaceId} spaceId={spaceId}
isAiMode={isAiMode}
/> />
</div> </div>
<Spotlight.ActionsList> <Spotlight.ActionsList>
{isAiMode ? (
<>
{query.length === 0 && (
<Spotlight.Empty>{t("Ask a question...")}</Spotlight.Empty>
)}
{query.length > 0 && (isAiLoading || aiSearchResult || streamingAnswer) && (
<AiSearchResult
result={aiSearchResult}
isLoading={isAiLoading}
streamingAnswer={streamingAnswer}
streamingSources={streamingSources}
/>
)}
{query.length > 0 && !isAiLoading && !aiSearchResult && (
<Spotlight.Empty>{t("No answer available")}</Spotlight.Empty>
)}
</>
) : (
<>
{query.length === 0 && resultItems.length === 0 && ( {query.length === 0 && resultItems.length === 0 && (
<Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty> <Spotlight.Empty>{t("Start typing to search...")}</Spotlight.Empty>
)} )}
@@ -98,6 +189,8 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
)} )}
{resultItems.length > 0 && <>{resultItems}</>} {resultItems.length > 0 && <>{resultItems}</>}
</>
)}
</Spotlight.ActionsList> </Spotlight.ActionsList>
</Spotlight.Root> </Spotlight.Root>
</> </>
@@ -19,6 +19,7 @@ export interface UseUnifiedSearchParams extends IPageSearchParams {
export function useUnifiedSearch( export function useUnifiedSearch(
params: UseUnifiedSearchParams, params: UseUnifiedSearchParams,
enabled: boolean = true,
): UseQueryResult<UnifiedSearchResult[], Error> { ): UseQueryResult<UnifiedSearchResult[], Error> {
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
@@ -38,6 +39,6 @@ export function useUnifiedSearch(
return await searchPage(backendParams); return await searchPage(backendParams);
} }
}, },
enabled: !!params.query, enabled: !!params.query && enabled,
}); });
} }
@@ -62,7 +62,8 @@ export default function SpaceSettingsModal({
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
<ScrollArea h={550} scrollbarSize={4} pr={8}> <ScrollArea h={580} scrollbarSize={5} pr={8}>
<div style={{ paddingBottom: "100px"}}>
<SpaceDetails <SpaceDetails
spaceId={space?.id} spaceId={space?.id}
readOnly={spaceAbility.cannot( readOnly={spaceAbility.cannot(
@@ -70,6 +71,8 @@ export default function SpaceSettingsModal({
SpaceCaslSubject.Settings, SpaceCaslSubject.Settings,
)} )}
/> />
</div>
</ScrollArea> </ScrollArea>
</Tabs.Panel> </Tabs.Panel>
@@ -113,7 +113,7 @@ export default function SpaceMembersList({
return ( return (
<> <>
<SearchInput onSearch={handleSearch} /> <SearchInput onSearch={handleSearch} />
<ScrollArea h={400}> <ScrollArea h={450}>
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}> <Table highlightOnHover verticalSpacing={8}>
<Table.Thead> <Table.Thead>
@@ -42,7 +42,7 @@ export async function deleteWorkspaceMember(data: {
await api.post("/workspace/members/delete", data); await api.post("/workspace/members/delete", data);
} }
export async function updateWorkspace(data: Partial<IWorkspace>) { export async function updateWorkspace(data: Partial<IWorkspace> & { aiSearch?: boolean }) {
const req = await api.post<IWorkspace>("/workspace/update", data); const req = await api.post<IWorkspace>("/workspace/update", data);
return req.data; return req.data;
} }
@@ -9,7 +9,7 @@ export interface IWorkspace {
defaultSpaceId: string; defaultSpaceId: string;
customDomain: string; customDomain: string;
enableInvite: boolean; enableInvite: boolean;
settings: any; settings: IWorkspaceSettings;
status: string; status: string;
enforceSso: boolean; enforceSso: boolean;
stripeCustomerId: string; stripeCustomerId: string;
@@ -24,6 +24,14 @@ export interface IWorkspace {
enforceMfa?: boolean; enforceMfa?: boolean;
} }
export interface IWorkspaceSettings {
ai?: IWorkspaceAiSettings;
}
export interface IWorkspaceAiSettings {
search?: boolean;
}
export interface ICreateInvite { export interface ICreateInvite {
role: string; role: string;
emails: string[]; emails: string[];
+1 -1
View File
@@ -1,4 +1,4 @@
export const INTERNAL_LINK_REGEX = export const INTERNAL_LINK_REGEX =
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/; /^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?(?:#(.*))?$/;
export const FIVE_MINUTES = 5 * 60 * 1000; export const FIVE_MINUTES = 5 * 60 * 1000;
+1 -1
View File
@@ -51,7 +51,7 @@ root.render(
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}> <MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider> <ModalsProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Notifications position="bottom-center" limit={3} /> <Notifications position="bottom-center" limit={3} zIndex={10000} />
<HelmetProvider> <HelmetProvider>
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<App /> <App />
+1
View File
@@ -21,6 +21,7 @@ const MemoizedHistoryModal = React.memo(HistoryModal);
export default function Page() { export default function Page() {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const { const {
data: page, data: page,
isLoading, isLoading,
+25 -18
View File
@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.23.2", "version": "0.24.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -30,6 +30,9 @@
"test:e2e": "jest --config test/jest-e2e.json" "test:e2e": "jest --config test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/azure": "^2.0.47",
"@ai-sdk/google": "^2.0.18",
"@ai-sdk/openai": "^2.0.46",
"@aws-sdk/client-s3": "3.701.0", "@aws-sdk/client-s3": "3.701.0",
"@aws-sdk/lib-storage": "3.701.0", "@aws-sdk/lib-storage": "3.701.0",
"@aws-sdk/s3-request-presigner": "3.701.0", "@aws-sdk/s3-request-presigner": "3.701.0",
@@ -37,51 +40,55 @@
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.0.3", "@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@langchain/textsplitters": "^0.1.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4", "@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.2", "@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.1.3", "@nestjs/common": "^11.1.9",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.3", "@nestjs/core": "^11.1.9",
"@nestjs/event-emitter": "^3.0.1", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "11.0.0",
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.3", "@nestjs/platform-fastify": "^11.1.9",
"@nestjs/platform-socket.io": "^11.1.3", "@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.0", "@nestjs/schedule": "^6.0.1",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.3", "@nestjs/websockets": "^11.1.9",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "0.0.28",
"@react-email/render": "1.0.2", "@react-email/render": "1.0.2",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"bcrypt": "^5.1.1", "ai": "^5.0.65",
"bullmq": "^5.61.0", "ai-sdk-ollama": "^0.12.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.65.0",
"cache-manager": "^6.4.3", "cache-manager": "^6.4.3",
"cheerio": "^1.1.0", "cheerio": "^1.1.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.3",
"cookie": "^1.0.2", "cookie": "^1.0.2",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"happy-dom": "^18.0.1", "happy-dom": "20.0.10",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0", "ldapts": "^7.4.0",
"mammoth": "^1.10.0", "mammoth": "^1.11.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.11",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otpauth": "^9.4.0", "otpauth": "^9.4.0",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.4.54", "pdfjs-dist": "^5.4.394",
"pg": "^8.16.0", "pg": "^8.16.3",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"postmark": "^4.0.5", "postmark": "^4.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -92,7 +99,7 @@
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"typesense": "^2.1.0", "typesense": "^2.1.0",
"ws": "^8.18.2", "ws": "^8.18.3",
"yauzl": "^3.2.0" "yauzl": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {
@@ -5,12 +5,12 @@ import { TaskItem } from '@tiptap/extension-task-item';
import { Underline } from '@tiptap/extension-underline'; import { Underline } from '@tiptap/extension-underline';
import { Superscript } from '@tiptap/extension-superscript'; import { Superscript } from '@tiptap/extension-superscript';
import SubScript from '@tiptap/extension-subscript'; import SubScript from '@tiptap/extension-subscript';
import { Highlight } from '@tiptap/extension-highlight';
import { Typography } from '@tiptap/extension-typography'; import { Typography } from '@tiptap/extension-typography';
import { TextStyle } from '@tiptap/extension-text-style'; import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color'; import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube'; import { Youtube } from '@tiptap/extension-youtube';
import { import {
Heading,
Callout, Callout,
Comment, Comment,
CustomCodeBlock, CustomCodeBlock,
@@ -33,7 +33,9 @@ import {
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
TableOfContentsNode, Highlight,
UniqueID,
addUniqueIdsToDoc,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -45,6 +47,11 @@ import { Node } from '@tiptap/pm/model';
export const tiptapExtensions = [ export const tiptapExtensions = [
StarterKit.configure({ StarterKit.configure({
codeBlock: false, codeBlock: false,
heading: false,
}),
Heading,
UniqueID.configure({
types: ['heading', 'paragraph'],
}), }),
Comment, Comment,
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
@@ -81,7 +88,6 @@ export const tiptapExtensions = [
Embed, Embed,
Mention, Mention,
Subpages, Subpages,
TableOfContentsNode,
] as any; ] as any;
export function jsonToHtml(tiptapJson: any) { export function jsonToHtml(tiptapJson: any) {
@@ -89,7 +95,14 @@ export function jsonToHtml(tiptapJson: any) {
} }
export function htmlToJson(html: string) { export function htmlToJson(html: string) {
return generateJSON(html, tiptapExtensions); const pmJson = generateJSON(html, tiptapExtensions);
try {
return addUniqueIdsToDoc(pmJson, tiptapExtensions);
} catch (error) {
console.warn('failed to add unique ids to doc', error);
return pmJson;
}
} }
export function jsonToText(tiptapJson: JSONContent) { export function jsonToText(tiptapJson: JSONContent) {
@@ -9,6 +9,7 @@ import { TokenService } from '../../core/auth/services/token.service';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission'; import { SpaceRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util'; import { getPageId } from '../collaboration.util';
@@ -23,6 +24,7 @@ export class AuthenticationExtension implements Extension {
private userRepo: UserRepo, private userRepo: UserRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
private readonly spaceMemberRepo: SpaceMemberRepo, private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
) {} ) {}
async onAuthenticate(data: onAuthenticatePayload) { async onAuthenticate(data: onAuthenticatePayload) {
@@ -68,10 +70,32 @@ export class AuthenticationExtension implements Extension {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
// Check page-level permissions
const { hasRestriction, canAccess, canEdit } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permissions
if (!canAccess) {
this.logger.warn(
`User ${user.id} denied page-level access to page: ${pageId}`,
);
throw new UnauthorizedException();
}
if (!canEdit) {
data.connection.readOnly = true;
this.logger.debug(
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
);
}
} else {
// No restrictions - use space-level permissions
if (userSpaceRole === SpaceRole.READER) { if (userSpaceRole === SpaceRole.READER) {
data.connection.readOnly = true; data.connection.readOnly = true;
this.logger.debug(`User granted readonly access to page: ${pageId}`); this.logger.debug(`User granted readonly access to page: ${pageId}`);
} }
}
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`); this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
@@ -35,6 +35,7 @@ export class PersistenceExtension implements Extension {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
async onLoadDocument(data: onLoadDocumentPayload) { async onLoadDocument(data: onLoadDocumentPayload) {
@@ -168,6 +169,11 @@ export class PersistenceExtension implements Extension {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
mentions: pageMentions, mentions: pageMentions,
} as IPageBacklinkJob); } as IPageBacklinkJob);
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
pageIds: [pageId],
workspaceId: page.workspaceId,
});
} }
} }
@@ -2,7 +2,17 @@ export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated', COLLAB_PAGE_UPDATED = 'collab.page.updated',
PAGE_CREATED = 'page.created', PAGE_CREATED = 'page.created',
PAGE_UPDATED = 'page.updated', PAGE_UPDATED = 'page.updated',
PAGE_CONTENT_UPDATED = 'page-content-updated',
PAGE_MOVED_TO_SPACE = 'page-moved-to-space',
PAGE_DELETED = 'page.deleted', PAGE_DELETED = 'page.deleted',
PAGE_SOFT_DELETED = 'page.soft_deleted', PAGE_SOFT_DELETED = 'page.soft_deleted',
PAGE_RESTORED = 'page.restored', PAGE_RESTORED = 'page.restored',
SPACE_CREATED = 'space.created',
SPACE_UPDATED = 'space.updated',
SPACE_DELETED = 'space.deleted',
WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted',
} }
@@ -14,3 +14,12 @@ export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see that it exists and join. OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see PRIVATE = 'private', // only added space users can see
} }
export enum PageAccessLevel {
RESTRICTED = 'restricted', // only specific users/groups can view or edit
}
export enum PagePermissionRole {
READER = 'reader', // can only view content and descendants
WRITER = 'writer', // can edit content, descendants, and add new users to permission
}
@@ -53,6 +53,7 @@ import { TokenService } from '../auth/services/token.service';
import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload'; import { JwtAttachmentPayload, JwtType } from '../auth/dto/jwt-payload';
import * as path from 'path'; import * as path from 'path';
import { RemoveIconDto } from './dto/attachment.dto'; import { RemoveIconDto } from './dto/attachment.dto';
import { PageAccessService } from '../page-access/page-access.service';
@Controller() @Controller()
export class AttachmentController { export class AttachmentController {
@@ -67,6 +68,7 @@ export class AttachmentController {
private readonly attachmentRepo: AttachmentRepo, private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly pageAccessService: PageAccessService,
) {} ) {}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -111,13 +113,8 @@ export class AttachmentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const spaceAbility = await this.spaceAbility.createForUser( // Checks both space-level and page-level edit permissions
user, await this.pageAccessService.validateCanEdit(page, user);
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId; const spaceId = page.spaceId;
@@ -171,15 +168,14 @@ export class AttachmentController {
throw new NotFoundException(); throw new NotFoundException();
} }
const spaceAbility = await this.spaceAbility.createForUser( const page = await this.pageRepo.findById(attachment.pageId);
user, if (!page) {
attachment.spaceId, throw new NotFoundException();
);
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
} }
// Checks both space-level and page-level view permissions
await this.pageAccessService.validateCanView(page, user);
try { try {
const fileStream = await this.storageService.read(attachment.filePath); const fileStream = await this.storageService.read(attachment.filePath);
res.headers({ res.headers({
@@ -24,6 +24,7 @@ import {
SpaceCaslSubject, SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type'; } from '../casl/interfaces/space-ability.type';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { PageAccessService } from '../page-access/page-access.service';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('comments') @Controller('comments')
@@ -33,6 +34,7 @@ export class CommentController {
private readonly commentRepo: CommentRepo, private readonly commentRepo: CommentRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {} ) {}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -47,10 +49,7 @@ export class CommentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); await this.pageAccessService.validateCanEdit(page, user);
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.commentService.create( return this.commentService.create(
{ {
@@ -75,10 +74,8 @@ export class CommentController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); await this.pageAccessService.validateCanView(page, user);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.commentService.findByPageId(page.id, pagination); return this.commentService.findByPageId(page.id, pagination);
} }
@@ -90,13 +87,13 @@ export class CommentController {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
const ability = await this.spaceAbility.createForUser( const page = await this.pageRepo.findById(comment.pageId);
user, if (!page) {
comment.spaceId, throw new NotFoundException('Page not found');
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
} }
await this.pageAccessService.validateCanView(page, user);
return comment; return comment;
} }
@@ -108,18 +105,13 @@ export class CommentController {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
const ability = await this.spaceAbility.createForUser( const page = await this.pageRepo.findById(comment.pageId);
user, if (!page) {
comment.spaceId, throw new NotFoundException('Page not found');
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(
'You must have space edit permission to edit comments',
);
} }
await this.pageAccessService.validateCanEdit(page, user);
return this.commentService.update(comment, dto, user); return this.commentService.update(comment, dto, user);
} }
@@ -131,40 +123,26 @@ export class CommentController {
throw new NotFoundException('Comment not found'); throw new NotFoundException('Comment not found');
} }
const ability = await this.spaceAbility.createForUser( const page = await this.pageRepo.findById(comment.pageId);
user, if (!page) {
comment.spaceId, throw new NotFoundException('Page not found');
);
// must be a space member with edit permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
} }
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
// Check if user is the comment owner // Check if user is the comment owner
const isOwner = comment.creatorId === user.id; const isOwner = comment.creatorId === user.id;
if (isOwner) { if (isOwner) {
/*
// Check if comment has children from other users
const hasChildrenFromOthers =
await this.commentRepo.hasChildrenFromOtherUsers(comment.id, user.id);
// Owner can delete if no children from other users
if (!hasChildrenFromOthers) {
await this.commentRepo.deleteComment(comment.id); await this.commentRepo.deleteComment(comment.id);
return; return;
} }
// If has children from others, only space admin can delete const ability = await this.spaceAbility.createForUser(
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { user,
throw new ForbiddenException( comment.spaceId,
'Only space admins can delete comments with replies from other users',
); );
}*/
await this.commentRepo.deleteComment(comment.id);
return;
}
// Space admin can delete any comment // Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
+2
View File
@@ -14,6 +14,7 @@ import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module'; import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module'; import { GroupModule } from './group/group.module';
import { CaslModule } from './casl/casl.module'; import { CaslModule } from './casl/casl.module';
import { PageAccessModule } from './page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware'; import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module'; import { ShareModule } from './share/share.module';
@@ -29,6 +30,7 @@ import { ShareModule } from './share/share.module';
SpaceModule, SpaceModule,
GroupModule, GroupModule,
CaslModule, CaslModule,
PageAccessModule,
ShareModule, ShareModule,
], ],
}) })
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PageAccessService } from './page-access.service';
@Global()
@Module({
providers: [PageAccessService],
exports: [PageAccessService],
})
export class PageAccessModule {}
@@ -0,0 +1,71 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Page, User } from '@docmost/db/types/entity.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
/**
* Validate user can view page, throws ForbiddenException if not.
* If page has restrictions: page-level permission determines access.
* If no restrictions: space-level permission determines access.
*/
async validateCanView(page: Page, user: User): Promise<void> {
// TODO: cache by pageId and userId.
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction, canAccess } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permission
if (!canAccess) {
throw new ForbiddenException();
}
}
// No restriction - space membership (checked above) is sufficient for view
}
/**
* Validate user can edit page, throws ForbiddenException if not.
* If page has restrictions: page-level writer permission determines access.
* If no restrictions: space-level edit permission determines access.
*/
async validateCanEdit(page: Page, user: User): Promise<void> {
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
// User must be at least a space member
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const { hasRestriction, canEdit } =
await this.pagePermissionRepo.getUserPageAccessLevel(user.id, page.id);
if (hasRestriction) {
// Page has restrictions - use page-level permission
if (!canEdit) {
throw new ForbiddenException();
}
} else {
// No restrictions - use space-level permission
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
}
}
@@ -0,0 +1,67 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { PagePermissionRole } from '../../../common/helpers/types/permission';
export class PageIdDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
export class RestrictPageDto extends PageIdDto {}
export class AddPagePermissionDto extends PageIdDto {
@IsEnum(PagePermissionRole)
role: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(25, {
message: 'userIds must be an array with no more than 25 elements',
})
@ArrayMinSize(1)
@IsUUID('all', { each: true })
userIds?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(25, {
message: 'groupIds must be an array with no more than 25 elements',
})
@ArrayMinSize(1)
@IsUUID('all', { each: true })
groupIds?: string[];
}
export class RemovePagePermissionDto extends PageIdDto {
@IsOptional()
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
groupId?: string;
}
export class UpdatePagePermissionRoleDto extends PageIdDto {
@IsEnum(PagePermissionRole)
role: string;
@IsOptional()
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
groupId?: string;
}
export class RemovePageRestrictionDto extends PageIdDto {}
@@ -0,0 +1,107 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { PagePermissionService } from './services/page-permission.service';
import {
AddPagePermissionDto,
PageIdDto,
RemovePagePermissionDto,
RemovePageRestrictionDto,
RestrictPageDto,
UpdatePagePermissionRoleDto,
} from './dto/page-permission.dto';
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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
@UseGuards(JwtAuthGuard)
@Controller('pages/permissions')
export class PagePermissionController {
constructor(
private readonly pagePermissionService: PagePermissionService,
) {}
@HttpCode(HttpStatus.OK)
@Post('restrict')
async restrictPage(
@Body() dto: RestrictPageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
await this.pagePermissionService.restrictPage(dto.pageId, user, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('add')
async addPagePermission(
@Body() dto: AddPagePermissionDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (
(!dto.userIds || dto.userIds.length === 0) &&
(!dto.groupIds || dto.groupIds.length === 0)
) {
throw new BadRequestException('userIds or groupIds is required');
}
await this.pagePermissionService.addPagePermissions(dto, user, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('remove')
async removePagePermission(
@Body() dto: RemovePagePermissionDto,
@AuthUser() user: User,
) {
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('userId or groupId is required');
}
await this.pagePermissionService.removePagePermission(dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('update-role')
async updatePagePermissionRole(
@Body() dto: UpdatePagePermissionRoleDto,
@AuthUser() user: User,
) {
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('userId or groupId is required');
}
await this.pagePermissionService.updatePagePermissionRole(dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('unrestrict')
async removePageRestriction(
@Body() dto: RemovePageRestrictionDto,
@AuthUser() user: User,
) {
await this.pagePermissionService.removePageRestriction(dto.pageId, user);
}
@HttpCode(HttpStatus.OK)
@Post('list')
async getPagePermissions(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
return this.pagePermissionService.getPagePermissions(
dto.pageId,
user,
pagination,
);
}
}
+89 -43
View File
@@ -1,23 +1,24 @@
import { import {
Controller, BadRequestException,
Post,
Body, Body,
Controller,
ForbiddenException,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UseGuards,
ForbiddenException,
NotFoundException, NotFoundException,
BadRequestException, Post,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { PageService } from './services/page.service'; import { PageService } from './services/page.service';
import { PageAccessService } from '../page-access/page-access.service';
import { CreatePageDto } from './dto/create-page.dto'; import { CreatePageDto } from './dto/create-page.dto';
import { UpdatePageDto } from './dto/update-page.dto'; import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto'; import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
import { import {
DeletePageDto,
PageHistoryIdDto, PageHistoryIdDto,
PageIdDto, PageIdDto,
PageInfoDto, PageInfoDto,
DeletePageDto,
} from './dto/page.dto'; } from './dto/page.dto';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
@@ -44,6 +45,7 @@ export class PageController {
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pageHistoryService: PageHistoryService, private readonly pageHistoryService: PageHistoryService,
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageAccessService: PageAccessService,
) {} ) {}
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -61,10 +63,7 @@ export class PageController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); await this.pageAccessService.validateCanView(page, user);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return page; return page;
} }
@@ -76,6 +75,17 @@ export class PageController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
if (createPageDto.parentPageId) {
// Creating under a parent page - check edit permission on parent
const parentPage = await this.pageRepo.findById(
createPageDto.parentPageId,
);
if (!parentPage || parentPage.spaceId !== createPageDto.spaceId) {
throw new NotFoundException('Parent page not found');
}
await this.pageAccessService.validateCanEdit(parentPage, user);
} else {
// Creating at root level - require space-level permission
const ability = await this.spaceAbility.createForUser( const ability = await this.spaceAbility.createForUser(
user, user,
createPageDto.spaceId, createPageDto.spaceId,
@@ -83,6 +93,7 @@ export class PageController {
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
}
return this.pageService.create(user.id, workspace.id, createPageDto); return this.pageService.create(user.id, workspace.id, createPageDto);
} }
@@ -96,17 +107,18 @@ export class PageController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); await this.pageAccessService.validateCanEdit(page, user);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.update(page, updatePageDto, user.id); return this.pageService.update(page, updatePageDto, user.id);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('delete') @Post('delete')
async delete(@Body() deletePageDto: DeletePageDto, @AuthUser() user: User) { async delete(
@Body() deletePageDto: DeletePageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(deletePageDto.pageId); const page = await this.pageRepo.findById(deletePageDto.pageId);
if (!page) { if (!page) {
@@ -122,37 +134,49 @@ export class PageController {
'Only space admins can permanently delete pages', 'Only space admins can permanently delete pages',
); );
} }
await this.pageService.forceDelete(deletePageDto.pageId); await this.pageService.forceDelete(deletePageDto.pageId, workspace.id);
} else { } else {
// Soft delete requires page manage permissions // User with edit permission can delete
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { await this.pageAccessService.validateCanEdit(page, user);
throw new ForbiddenException();
} await this.pageService.removePage(
await this.pageService.remove(deletePageDto.pageId, user.id); deletePageDto.pageId,
user.id,
workspace.id,
);
} }
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('restore') @Post('restore')
async restore(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) { async restore(
@Body() pageIdDto: PageIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(pageIdDto.pageId); const page = await this.pageRepo.findById(pageIdDto.pageId);
if (!page) { if (!page) {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
//Todo: currently, this means if they are not admins, they need to add a space admin to the page, which is not possible as it was soft-deleted
// so page is virtually lost. Fix.
const ability = await this.spaceAbility.createForUser(user, page.spaceId); const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
await this.pageRepo.restorePage(pageIdDto.pageId); //TODO: can users with page level edit, but no space level edit restore pages they can edit?
// Return the restored page data with hasChildren info // Check page-level edit permission (if restoring to a restricted ancestor)
const restoredPage = await this.pageRepo.findById(pageIdDto.pageId, { await this.pageAccessService.validateCanEdit(page, user);
await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id);
return this.pageRepo.findById(pageIdDto.pageId, {
includeHasChildren: true, includeHasChildren: true,
}); });
return restoredPage;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -174,6 +198,7 @@ export class PageController {
return this.pageService.getRecentSpacePages( return this.pageService.getRecentSpacePages(
recentPageDto.spaceId, recentPageDto.spaceId,
user.id,
pagination, pagination,
); );
} }
@@ -188,6 +213,7 @@ export class PageController {
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
@AuthUser() user: User, @AuthUser() user: User,
) { ) {
//TODO: should space admin see deleted pages they dont have access to?
if (deletedPageDto.spaceId) { if (deletedPageDto.spaceId) {
const ability = await this.spaceAbility.createForUser( const ability = await this.spaceAbility.createForUser(
user, user,
@@ -205,7 +231,6 @@ export class PageController {
} }
} }
// TODO: scope to workspaces
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')
async getPageHistory( async getPageHistory(
@@ -218,10 +243,7 @@ export class PageController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); await this.pageAccessService.validateCanView(page, user);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageHistoryService.findHistoryByPageId(page.id, pagination); return this.pageHistoryService.findHistoryByPageId(page.id, pagination);
} }
@@ -237,13 +259,14 @@ export class PageController {
throw new NotFoundException('Page history not found'); throw new NotFoundException('Page history not found');
} }
const ability = await this.spaceAbility.createForUser( // Get the page to check permissions
user, const page = await this.pageRepo.findById(history.pageId);
history.spaceId, if (!page) {
); throw new NotFoundException('Page not found');
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
} }
await this.pageAccessService.validateCanView(page, user);
return history; return history;
} }
@@ -275,7 +298,12 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId); return this.pageService.getSidebarPages(
spaceId,
pagination,
dto.pageId,
user.id,
);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -305,7 +333,11 @@ export class PageController {
throw new ForbiddenException(); throw new ForbiddenException();
} }
return this.pageService.movePageToSpace(movedPage, dto.spaceId); // Check page-level edit permission on the source page
await this.pageAccessService.validateCanEdit(movedPage, user);
// Moves only accessible pages; inaccessible child pages become root pages in original space
return this.pageService.movePageToSpace(movedPage, dto.spaceId, user.id);
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -316,6 +348,10 @@ export class PageController {
throw new NotFoundException('Page to copy not found'); throw new NotFoundException('Page to copy not found');
} }
// Check page-level view permission on the source page (need to read to copy)
// Inaccessible child branches are automatically skipped during duplication
await this.pageAccessService.validateCanView(copiedPage, user);
// If spaceId is provided, it's a copy to different space // If spaceId is provided, it's a copy to different space
if (dto.spaceId) { if (dto.spaceId) {
const abilities = await Promise.all([ const abilities = await Promise.all([
@@ -358,10 +394,22 @@ export class PageController {
user, user,
movedPage.spaceId, movedPage.spaceId,
); );
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
// Check page-level edit permission
await this.pageAccessService.validateCanEdit(movedPage, user);
// If moving to a new parent, check permission on the target parent
if (dto.parentPageId && dto.parentPageId !== movedPage.parentPageId) {
const targetParent = await this.pageRepo.findById(dto.parentPageId);
if (targetParent) {
await this.pageAccessService.validateCanEdit(targetParent, user);
}
}
return this.pageService.movePage(dto, movedPage); return this.pageService.movePage(dto, movedPage);
} }
@@ -373,10 +421,8 @@ export class PageController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); await this.pageAccessService.validateCanView(page, user);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
return this.pageService.getPageBreadCrumbs(page.id); return this.pageService.getPageBreadCrumbs(page.id);
} }
} }
+5 -3
View File
@@ -3,12 +3,14 @@ import { PageService } from './services/page.service';
import { PageController } from './page.controller'; import { PageController } from './page.controller';
import { PageHistoryService } from './services/page-history.service'; import { PageHistoryService } from './services/page-history.service';
import { TrashCleanupService } from './services/trash-cleanup.service'; import { TrashCleanupService } from './services/trash-cleanup.service';
import { PagePermissionService } from './services/page-permission.service';
import { PagePermissionController } from './page-permission.controller';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
@Module({ @Module({
controllers: [PageController], controllers: [PageController, PagePermissionController],
providers: [PageService, PageHistoryService, TrashCleanupService], providers: [PageService, PageHistoryService, TrashCleanupService, PagePermissionService],
exports: [PageService, PageHistoryService], exports: [PageService, PageHistoryService, PagePermissionService],
imports: [StorageModule], imports: [StorageModule],
}) })
export class PageModule {} export class PageModule {}
@@ -0,0 +1,438 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import {
AddPagePermissionDto,
RemovePagePermissionDto,
UpdatePagePermissionRoleDto,
} from '../dto/page-permission.dto';
import { Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
PageAccessLevel,
PagePermissionRole,
} from '../../../common/helpers/types/permission';
import { executeTx } from '@docmost/db/utils';
import SpaceAbilityFactory from '../../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
@Injectable()
export class PagePermissionService {
constructor(
private pagePermissionRepo: PagePermissionRepo,
private pageRepo: PageRepo,
private spaceAbility: SpaceAbilityFactory,
@InjectKysely() private readonly db: KyselyDB,
) {}
async restrictPage(
pageId: string,
authUser: User,
workspaceId: string,
): Promise<void> {
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(authUser, page.spaceId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
// TODO: does this check if any of the page's ancestor's is restricted and the user don't have access to it?
// to have access to this page, they must already have access to the page if any of it's ancestor's is restricted
const existingAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
if (existingAccess) {
throw new BadRequestException('Page is already restricted');
}
await executeTx(this.db, async (trx) => {
const pageAccess = await this.pagePermissionRepo.insertPageAccess(
{
pageId: pageId,
workspaceId: workspaceId,
accessLevel: PageAccessLevel.RESTRICTED,
creatorId: authUser.id,
},
trx,
);
await this.pagePermissionRepo.insertPagePermissions(
[
{
pageAccessId: pageAccess.id,
userId: authUser.id,
role: PagePermissionRole.WRITER,
addedById: authUser.id,
},
],
trx,
);
});
}
async addPagePermissions(
dto: AddPagePermissionDto,
authUser: User,
workspaceId: string,
): Promise<void> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
dto.pageId,
);
if (!pageAccess) {
throw new BadRequestException(
'Page is not restricted. Restrict the page first.',
);
}
let validUsers = [];
let validGroups = [];
if (dto.userIds && dto.userIds.length > 0) {
validUsers = await this.db
.selectFrom('users')
.select(['id'])
.where('id', 'in', dto.userIds)
.where('workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pagePermissions')
.select('id')
.whereRef('pagePermissions.userId', '=', 'users.id')
.where('pagePermissions.pageAccessId', '=', pageAccess.id),
),
),
)
.execute();
}
if (dto.groupIds && dto.groupIds.length > 0) {
validGroups = await this.db
.selectFrom('groups')
.select(['id'])
.where('id', 'in', dto.groupIds)
.where('workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pagePermissions')
.select('id')
.whereRef('pagePermissions.groupId', '=', 'groups.id')
.where('pagePermissions.pageAccessId', '=', pageAccess.id),
),
),
)
.execute();
}
const permissionsToAdd = [];
for (const user of validUsers) {
permissionsToAdd.push({
pageAccessId: pageAccess.id,
userId: user.id,
role: dto.role,
addedById: authUser.id,
});
}
for (const group of validGroups) {
permissionsToAdd.push({
pageAccessId: pageAccess.id,
groupId: group.id,
role: dto.role,
addedById: authUser.id,
});
}
if (permissionsToAdd.length > 0) {
await this.pagePermissionRepo.insertPagePermissions(permissionsToAdd);
}
}
async removePagePermission(
dto: RemovePagePermissionDto,
authUser: User,
): Promise<void> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
dto.pageId,
);
if (!pageAccess) {
throw new BadRequestException('Page is not restricted');
}
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('Please provide a userId or groupId');
}
if (dto.userId) {
const permission = await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
dto.userId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.deletePagePermissionByUserId(
pageAccess.id,
dto.userId,
);
} else if (dto.groupId) {
const permission =
await this.pagePermissionRepo.findPagePermissionByGroupId(
pageAccess.id,
dto.groupId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.deletePagePermissionByGroupId(
pageAccess.id,
dto.groupId,
);
}
}
async updatePagePermissionRole(
dto: UpdatePagePermissionRoleDto,
authUser: User,
): Promise<void> {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
dto.pageId,
);
if (!pageAccess) {
throw new BadRequestException('Page is not restricted');
}
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('Please provide a userId or groupId');
}
if (dto.userId) {
const permission =
await this.pagePermissionRepo.findPagePermissionByUserId(
pageAccess.id,
dto.userId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === dto.role) {
return;
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.updatePagePermissionRole(
pageAccess.id,
dto.role,
{ userId: dto.userId },
);
} else if (dto.groupId) {
const permission =
await this.pagePermissionRepo.findPagePermissionByGroupId(
pageAccess.id,
dto.groupId,
);
if (!permission) {
throw new NotFoundException('Permission not found');
}
if (permission.role === dto.role) {
return;
}
if (permission.role === PagePermissionRole.WRITER) {
await this.validateLastWriter(pageAccess.id);
}
await this.pagePermissionRepo.updatePagePermissionRole(
pageAccess.id,
dto.role,
{ groupId: dto.groupId },
);
}
}
async removePageRestriction(pageId: string, authUser: User): Promise<void> {
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
await this.validateWriteAccess(page, authUser);
const pageAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
if (!pageAccess) {
throw new BadRequestException('Page is not restricted');
}
await this.pagePermissionRepo.deletePageAccess(pageId);
}
async getPagePermissions(
pageId: string,
authUser: User,
pagination: PaginationOptions,
) {
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(authUser, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const pageAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
if (!pageAccess) {
return {
items: [],
pagination: {
page: 1,
perPage: pagination.limit,
totalItems: 0,
totalPages: 0,
hasNextPage: false,
hasPrevPage: false,
},
};
}
return this.pagePermissionRepo.getPagePermissionsPaginated(
pageAccess.id,
pagination,
);
}
async validateLastWriter(pageAccessId: string): Promise<void> {
const writerCount =
await this.pagePermissionRepo.countWritersByPageAccessId(pageAccessId);
if (writerCount <= 1) {
throw new BadRequestException(
'There must be at least one user with "Can edit" permission',
);
}
}
/**
* Check if user has writer permission on ALL restricted ancestors of a page.
* Used for permission management operations.
*/
async hasWritePermission(userId: string, pageId: string): Promise<boolean> {
const hasRestriction =
await this.pagePermissionRepo.hasRestrictedAncestor(pageId);
if (!hasRestriction) {
return false; // no restrictions, defer to space permissions
}
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
}
async hasPageAccess(pageId: string): Promise<boolean> {
const pageAccess =
await this.pagePermissionRepo.findPageAccessByPageId(pageId);
return !!pageAccess;
}
async validateWriteAccess(page: Page, user: User): Promise<void> {
const hasWritePermission = await this.hasWritePermission(user.id, page.id);
if (hasWritePermission) {
return;
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
}
/**
* Check if user can view a page.
* User must have permission (reader or writer) on EVERY restricted ancestor.
* Returns true if:
* - No ancestors are restricted (defer to space permission)
* - User has permission on all restricted ancestors
*/
async canViewPage(userId: string, pageId: string): Promise<boolean> {
return this.pagePermissionRepo.canUserAccessPage(userId, pageId);
}
/**
* Check if user can edit a page.
* User must have WRITER permission on EVERY restricted ancestor.
* Returns true if:
* - No ancestors are restricted (defer to space permission)
* - User has writer permission on all restricted ancestors
*/
async canEditPage(userId: string, pageId: string): Promise<boolean> {
return this.pagePermissionRepo.canUserEditPage(userId, pageId);
}
/**
* Filter page IDs to only those the user can access.
*/
async filterAccessiblePages(
pageIds: string[],
userId: string,
): Promise<string[]> {
const results =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
return results.map((r) => r.id);
}
}
@@ -7,6 +7,7 @@ import {
import { CreatePageDto } from '../dto/create-page.dto'; import { CreatePageDto } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto'; import { UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { import {
@@ -47,13 +48,70 @@ export class PageService {
constructor( constructor(
private pageRepo: PageRepo, private pageRepo: PageRepo,
private pagePermissionRepo: PagePermissionRepo,
private attachmentRepo: AttachmentRepo, private attachmentRepo: AttachmentRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly storageService: StorageService, private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
) {} ) {}
/**
* Filters a list of pages to only those accessible to the user while maintaining tree integrity.
* A page is included only if:
* 1. The user has access to it
* 2. Its parent is also included (or it's the root page)
* This ensures that if a middle page is inaccessible, its entire subtree is excluded.
*/
private async filterAccessibleTreePages<T extends { id: string; parentPageId: string | null }>(
pages: T[],
rootPageId: string,
userId: string,
): Promise<T[]> {
if (pages.length === 0) return [];
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
// Build a map for quick lookup
const pageMap = new Map(pages.map((p) => [p.id, p]));
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
// Process pages in a way that ensures parents are processed before children
// We do this by iterating until no more pages can be added
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is already included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
async findById( async findById(
pageId: string, pageId: string,
includeContent?: boolean, includeContent?: boolean,
@@ -166,7 +224,7 @@ export class PageService {
page.id, page.id,
); );
return await this.pageRepo.findById(page.id, { return this.pageRepo.findById(page.id, {
includeSpace: true, includeSpace: true,
includeContent: true, includeContent: true,
includeCreator: true, includeCreator: true,
@@ -179,6 +237,7 @@ export class PageService {
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
pageId?: string, pageId?: string,
userId?: string,
): Promise<any> { ): Promise<any> {
let query = this.db let query = this.db
.selectFrom('pages') .selectFrom('pages')
@@ -204,16 +263,83 @@ export class PageService {
query = query.where('parentPageId', 'is', null); query = query.where('parentPageId', 'is', null);
} }
const result = executeWithPagination(query, { const result = await executeWithPagination(query, {
page: pagination.page, page: pagination.page,
perPage: 250, perPage: 250,
}); });
if (userId && result.items.length > 0) {
const pageIds = result.items.map((p: any) => p.id);
// Single query to get accessible pages with their edit permissions
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const permissionMap = new Map(
accessiblePages.map((p) => [p.id, p.canEdit]),
);
// Filter and add canEdit flag in one pass
result.items = result.items
.filter((p: any) => permissionMap.has(p.id))
.map((p: any) => ({
...p,
canEdit: permissionMap.get(p.id),
}));
// For pages with hasChildren: true, verify they have accessible children
const pagesWithChildren = result.items.filter((p: any) => p.hasChildren);
if (pagesWithChildren.length > 0) {
const parentIds = pagesWithChildren.map((p: any) => p.id);
const parentsWithAccessibleChildren =
await this.pagePermissionRepo.getParentIdsWithAccessibleChildren(
parentIds,
userId,
);
const hasAccessibleChildrenSet = new Set(parentsWithAccessibleChildren);
result.items = result.items.map((p: any) => ({
...p,
hasChildren: p.hasChildren && hasAccessibleChildrenSet.has(p.id),
}));
}
}
return result; return result;
} }
async movePageToSpace(rootPage: Page, spaceId: string) { async movePageToSpace(rootPage: Page, spaceId: string, userId: string) {
const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: false,
});
// Filter to only accessible pages while maintaining tree integrity
const accessiblePages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
userId,
);
const accessibleIds = new Set(accessiblePages.map((p) => p.id));
// Find inaccessible pages whose parent is being moved - these need to be orphaned
const pagesToOrphan = allPages.filter(
(p) => !accessibleIds.has(p.id) && p.parentPageId && accessibleIds.has(p.parentPageId),
);
await executeTx(this.db, async (trx) => { await executeTx(this.db, async (trx) => {
// Orphan inaccessible child pages (make them root pages in original space)
for (const page of pagesToOrphan) {
const orphanPosition = await this.nextPagePosition(rootPage.spaceId, null);
await this.pageRepo.updatePage(
{ parentPageId: null, position: orphanPosition },
page.id,
trx,
);
}
// Update root page // Update root page
const nextPosition = await this.nextPagePosition(spaceId); const nextPosition = await this.nextPagePosition(spaceId);
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
@@ -221,40 +347,51 @@ export class PageService {
rootPage.id, rootPage.id,
trx, trx,
); );
const pageIds = await this.pageRepo
.getPageAndDescendants(rootPage.id, { includeContent: false }) const pageIdsToMove = accessiblePages.map((p) => p.id);
.then((pages) => pages.map((page) => page.id));
// The first id is the root page id if (pageIdsToMove.length > 1) {
if (pageIds.length > 1) { // Update sub pages (all accessible pages except root)
// Update sub pages
await this.pageRepo.updatePages( await this.pageRepo.updatePages(
{ spaceId }, { spaceId },
pageIds.filter((id) => id !== rootPage.id), pageIdsToMove.filter((id) => id !== rootPage.id),
trx, trx,
); );
} }
if (pageIds.length > 0) { if (pageIdsToMove.length > 0) {
// Clear page-level permissions - moved pages inherit destination space permissions
// (page_permissions cascade deletes via foreign key)
await trx
.deleteFrom('pageAccess')
.where('pageId', 'in', pageIdsToMove)
.execute();
// update spaceId in shares // update spaceId in shares
await trx await trx
.updateTable('shares') .updateTable('shares')
.set({ spaceId: spaceId }) .set({ spaceId: spaceId })
.where('pageId', 'in', pageIds) .where('pageId', 'in', pageIdsToMove)
.execute(); .execute();
// Update comments // Update comments
await trx await trx
.updateTable('comments') .updateTable('comments')
.set({ spaceId: spaceId }) .set({ spaceId: spaceId })
.where('pageId', 'in', pageIds) .where('pageId', 'in', pageIdsToMove)
.execute(); .execute();
// Update attachments // Update attachments
await this.attachmentRepo.updateAttachmentsByPageId( await this.attachmentRepo.updateAttachmentsByPageId(
{ spaceId }, { spaceId },
pageIds, pageIdsToMove,
trx, trx,
); );
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
} }
}); });
} }
@@ -278,10 +415,17 @@ export class PageService {
nextPosition = await this.nextPagePosition(spaceId); nextPosition = await this.nextPagePosition(spaceId);
} }
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, { const allPages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
includeContent: true, includeContent: true,
}); });
// Filter to only accessible pages while maintaining tree integrity
const pages = await this.filterAccessibleTreePages(
allPages,
rootPage.id,
authUser.id,
);
const pageMap = new Map<string, CopyPageMapEntry>(); const pageMap = new Map<string, CopyPageMapEntry>();
pages.forEach((page) => { pages.forEach((page) => {
pageMap.set(page.id, { pageMap.set(page.id, {
@@ -381,7 +525,12 @@ export class PageService {
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
creatorId: authUser.id, creatorId: authUser.id,
lastUpdatedById: authUser.id, lastUpdatedById: authUser.id,
parentPageId: page.parentPageId parentPageId:
page.id === rootPage.id
? isDuplicateInSameSpace
? rootPage.parentPageId
: null
: page.parentPageId
? pageMap.get(page.parentPageId)?.newPageId ? pageMap.get(page.parentPageId)?.newPageId
: null, : null,
}; };
@@ -393,6 +542,7 @@ export class PageService {
const insertedPageIds = insertablePages.map((page) => page.id); const insertedPageIds = insertablePages.map((page) => page.id);
this.eventEmitter.emit(EventName.PAGE_CREATED, { this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: insertedPageIds, pageIds: insertedPageIds,
workspaceId: authUser.workspaceId,
}); });
//TODO: best to handle this in a queue //TODO: best to handle this in a queue
@@ -561,16 +711,43 @@ export class PageService {
async getRecentSpacePages( async getRecentSpacePages(
spaceId: string, spaceId: string,
userId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Page>> { ): Promise<PaginationResult<Page>> {
return await this.pageRepo.getRecentPagesInSpace(spaceId, pagination); const result = await this.pageRepo.getRecentPagesInSpace(spaceId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
} }
async getRecentPages( async getRecentPages(
userId: string, userId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
): Promise<PaginationResult<Page>> { ): Promise<PaginationResult<Page>> {
return await this.pageRepo.getRecentPages(userId, pagination); const result = await this.pageRepo.getRecentPages(userId, pagination);
if (result.items.length > 0) {
const pageIds = result.items.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
result.items = result.items.filter((p) => accessibleSet.has(p.id));
}
return result;
} }
async getDeletedSpacePages( async getDeletedSpacePages(
@@ -580,7 +757,7 @@ export class PageService {
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination); return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
} }
async forceDelete(pageId: string): Promise<void> { async forceDelete(pageId: string, workspaceId: string): Promise<void> {
// Get all descendant IDs (including the page itself) using recursive CTE // Get all descendant IDs (including the page itself) using recursive CTE
const descendants = await this.db const descendants = await this.db
.withRecursive('page_descendants', (db) => .withRecursive('page_descendants', (db) =>
@@ -623,11 +800,16 @@ export class PageService {
await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute(); await this.db.deleteFrom('pages').where('id', 'in', pageIds).execute();
this.eventEmitter.emit(EventName.PAGE_DELETED, { this.eventEmitter.emit(EventName.PAGE_DELETED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId,
}); });
} }
} }
async remove(pageId: string, userId: string): Promise<void> { async removePage(
await this.pageRepo.removePage(pageId, userId); pageId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.pageRepo.removePage(pageId, userId, workspaceId);
} }
} }
+29 -3
View File
@@ -7,6 +7,7 @@ import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')(); const tsquery = require('pg-tsquery')();
@@ -18,6 +19,7 @@ export class SearchService {
private pageRepo: PageRepo, private pageRepo: PageRepo,
private shareRepo: ShareRepo, private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo, private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
) {} ) {}
async searchPage( async searchPage(
@@ -62,7 +64,7 @@ export class SearchService {
) )
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.orderBy('rank', 'desc') .orderBy('rank', 'desc')
.limit(searchParams.limit | 20) .limit(searchParams.limit | 25)
.offset(searchParams.offset || 0); .offset(searchParams.offset || 0);
if (!searchParams.shareId) { if (!searchParams.shareId) {
@@ -118,10 +120,22 @@ export class SearchService {
} }
//@ts-ignore //@ts-ignore
queryResults = await queryResults.execute(); let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
opts.userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
results = results.filter((r: any) => accessibleSet.has(r.id));
}
//@ts-ignore //@ts-ignore
const searchResults = queryResults.map((result: SearchResponseDto) => { const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) { if (result.highlight) {
result.highlight = result.highlight result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ') .replace(/\r\n|\r|\n/g, ' ')
@@ -210,6 +224,18 @@ export class SearchService {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds); pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
pages = await pageSearch.execute(); pages = await pageSearch.execute();
} }
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pageIds,
userId,
);
const accessibleSet = new Set(accessiblePages.map((p) => p.id));
pages = pages.filter((p) => accessibleSet.has(p.id));
}
} }
return { users, groups, pages }; return { users, groups, pages };
+46 -9
View File
@@ -26,6 +26,8 @@ import {
UpdateShareDto, UpdateShareDto,
} from './dto/share.dto'; } from './dto/share.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageAccessService } from '../page-access/page-access.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
@@ -41,6 +43,8 @@ export class ShareController {
private readonly spaceAbility: SpaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory,
private readonly shareRepo: ShareRepo, private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly pageAccessService: PageAccessService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
) {} ) {}
@@ -96,6 +100,7 @@ export class ShareController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
// TODO: look into permission
const page = await this.pageRepo.findById(dto.pageId); const page = await this.pageRepo.findById(dto.pageId);
if (!page) { if (!page) {
throw new NotFoundException('Shared page not found'); throw new NotFoundException('Shared page not found');
@@ -122,9 +127,21 @@ export class ShareController {
throw new NotFoundException('Page not found'); throw new NotFoundException('Page not found');
} }
const ability = await this.spaceAbility.createForUser(user, page.spaceId); // User must be able to edit the page to create a share
if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Share)) { await this.pageAccessService.validateCanEdit(page, user);
throw new ForbiddenException();
// Block includeSubPages if user cannot access all descendants
if (createShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
} }
return this.shareService.createShare({ return this.shareService.createShare({
@@ -144,9 +161,26 @@ export class ShareController {
throw new NotFoundException('Share not found'); throw new NotFoundException('Share not found');
} }
const ability = await this.spaceAbility.createForUser(user, share.spaceId); const page = await this.pageRepo.findById(share.pageId);
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Share)) { if (!page) {
throw new ForbiddenException(); throw new NotFoundException('Page not found');
}
// User must be able to edit the page to update its share
await this.pageAccessService.validateCanEdit(page, user);
// Block includeSubPages if user cannot access all descendants
if (updateShareDto.includeSubPages) {
const hasInaccessible =
await this.pagePermissionRepo.hasInaccessibleDescendants(
page.id,
user.id,
);
if (hasInaccessible) {
throw new BadRequestException(
'Cannot share subpages: restricted pages found',
);
}
} }
return this.shareService.updateShare(share.id, updateShareDto); return this.shareService.updateShare(share.id, updateShareDto);
@@ -161,11 +195,14 @@ export class ShareController {
throw new NotFoundException('Share not found'); throw new NotFoundException('Share not found');
} }
const ability = await this.spaceAbility.createForUser(user, share.spaceId); const page = await this.pageRepo.findById(share.pageId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Share)) { if (!page) {
throw new ForbiddenException(); throw new NotFoundException('Page not found');
} }
// User must be able to edit the page to delete its share
await this.pageAccessService.validateCanEdit(page, user);
await this.shareRepo.deleteShare(share.id); await this.shareRepo.deleteShare(share.id);
} }
+115 -4
View File
@@ -19,6 +19,7 @@ import {
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
import { Node } from '@tiptap/pm/model'; import { Node } from '@tiptap/pm/model';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { updateAttachmentAttr } from './share.util'; import { updateAttachmentAttr } from './share.util';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
@@ -31,6 +32,7 @@ export class ShareService {
constructor( constructor(
private readonly shareRepo: ShareRepo, private readonly shareRepo: ShareRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
) {} ) {}
@@ -42,16 +44,114 @@ export class ShareService {
} }
if (share.includeSubPages) { if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendants(share.pageId, { const allPages = await this.pageRepo.getPageAndDescendants(share.pageId, {
includeContent: false, includeContent: false,
}); });
return { share, pageTree: pageList }; // Filter out restricted pages and maintain tree integrity
const filteredPages = await this.filterPublicPages(allPages, share.pageId);
return { share, pageTree: filteredPages };
} else { } else {
return { share, pageTree: [] }; return { share, pageTree: [] };
} }
} }
/**
* Filter pages for public share - exclude restricted pages.
* A page is included only if:
* 1. It has no page_access restriction AND
* 2. Its parent is also included (or it's the root)
*/
private async filterPublicPages<
T extends { id: string; parentPageId: string | null },
>(pages: T[], rootPageId: string): Promise<T[]> {
if (pages.length === 0) return [];
// Get all restricted page IDs
const restrictedIds =
await this.pagePermissionRepo.getRestrictedDescendantIds(rootPageId);
const restrictedSet = new Set(restrictedIds);
// Include pages that are NOT restricted and have valid parent chain
const includedIds = new Set<string>();
let changed = true;
while (changed) {
changed = false;
for (const page of pages) {
if (includedIds.has(page.id)) continue;
if (restrictedSet.has(page.id)) continue;
// Root page: include if not restricted
if (page.id === rootPageId) {
includedIds.add(page.id);
changed = true;
continue;
}
// Non-root: include if parent is included
if (page.parentPageId && includedIds.has(page.parentPageId)) {
includedIds.add(page.id);
changed = true;
}
}
}
return pages.filter((p) => includedIds.has(p.id));
}
/**
* Check if a specific page is accessible within a public share.
* A page is accessible if no page in its ancestor chain
* (from the page up to and including the share root) has a page_access restriction.
*/
private async isPagePubliclyAccessible(
pageId: string,
shareRootPageId: string,
): Promise<boolean> {
if (pageId === shareRootPageId) {
const hasRestriction = await this.db
.selectFrom('pageAccess')
.select('id')
.where('pageId', '=', pageId)
.executeTakeFirst();
return !hasRestriction;
}
// Get the depth from share root to the requested page
const shareToPage = await this.db
.selectFrom('pageHierarchy')
.select('depth')
.where('ancestorId', '=', shareRootPageId)
.where('descendantId', '=', pageId)
.executeTakeFirst();
if (!shareToPage) {
return false;
}
// Get all ancestor IDs in the chain from pageId to shareRootPageId
const chainPageIds = await this.db
.selectFrom('pageHierarchy')
.select('ancestorId')
.where('descendantId', '=', pageId)
.where('depth', '<=', shareToPage.depth)
.where('depth', '>', 0)
.execute();
const idsToCheck = [pageId, ...chainPageIds.map((c) => c.ancestorId)];
// Check if any page in the chain has a restriction
const hasRestricted = await this.db
.selectFrom('pageAccess')
.select('pageId')
.where('pageId', 'in', idsToCheck)
.executeTakeFirst();
return !hasRestricted;
}
async createShare(opts: { async createShare(opts: {
authUserId: string; authUserId: string;
workspaceId: string; workspaceId: string;
@@ -69,8 +169,8 @@ export class ShareService {
return await this.shareRepo.insertShare({ return await this.shareRepo.insertShare({
key: nanoIdGen().toLowerCase(), key: nanoIdGen().toLowerCase(),
pageId: page.id, pageId: page.id,
includeSubPages: createShareDto.includeSubPages || true, includeSubPages: createShareDto.includeSubPages ?? false,
searchIndexing: createShareDto.searchIndexing || true, searchIndexing: createShareDto.searchIndexing ?? false,
creatorId: authUserId, creatorId: authUserId,
spaceId: page.spaceId, spaceId: page.spaceId,
workspaceId, workspaceId,
@@ -103,6 +203,17 @@ export class ShareService {
throw new NotFoundException('Shared page not found'); throw new NotFoundException('Shared page not found');
} }
// For descendant pages, verify the ancestor chain has no restrictions
if (share.level > 0) {
const isAccessible = await this.isPagePubliclyAccessible(
dto.pageId,
share.pageId,
);
if (!isAccessible) {
throw new NotFoundException('Shared page not found');
}
}
const page = await this.pageRepo.findById(dto.pageId, { const page = await this.pageRepo.findById(dto.pageId, {
includeContent: true, includeContent: true,
includeCreator: true, includeCreator: true,
@@ -22,4 +22,12 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
restrictApiToAdmins: boolean; restrictApiToAdmins: boolean;
@IsOptional()
@IsBoolean()
aiSearch: boolean;
@IsOptional()
@IsBoolean()
generativeAi: boolean;
} }
@@ -33,6 +33,7 @@ import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { generateRandomSuffixNumbers } from '../../../common/helpers'; import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
@@ -50,6 +51,7 @@ export class WorkspaceService {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
async findById(workspaceId: string) { async findById(workspaceId: string) {
@@ -312,6 +314,51 @@ export class WorkspaceService {
delete updateWorkspaceDto.restrictApiToAdmins; delete updateWorkspaceDto.restrictApiToAdmins;
} }
if (typeof updateWorkspaceDto.aiSearch !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'search',
updateWorkspaceDto.aiSearch,
);
if (updateWorkspaceDto.aiSearch) {
const tableExists = await isPageEmbeddingsTableExists(this.db);
if (!tableExists) {
throw new BadRequestException(
'Failed to activate. Make sure pgvector postgres extension is installed.',
);
}
await this.aiQueue.add(QueueJob.WORKSPACE_CREATE_EMBEDDINGS, {
workspaceId,
});
} else {
// Schedule deletion after 24 hours
const deleteJobId = `ai-search-disabled-${workspaceId}`;
await this.aiQueue.add(
QueueJob.WORKSPACE_DELETE_EMBEDDINGS,
{ workspaceId },
{
jobId: deleteJobId,
delay: 24 * 60 * 60 * 1000,
removeOnComplete: true,
removeOnFail: true,
},
);
}
delete updateWorkspaceDto.aiSearch;
}
if (typeof updateWorkspaceDto.generativeAi !== 'undefined') {
await this.workspaceRepo.updateAiSettings(
workspaceId,
'generative',
updateWorkspaceDto.generativeAi,
);
delete updateWorkspaceDto.generativeAi;
}
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId); await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, { const workspace = await this.workspaceRepo.findById(workspaceId, {
@@ -16,6 +16,7 @@ import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo'; import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageRepo } from './repos/page/page.repo'; import { PageRepo } from './repos/page/page.repo';
import { PagePermissionRepo } from './repos/page/page-permission.repo';
import { CommentRepo } from './repos/comment/comment.repo'; import { CommentRepo } from './repos/comment/comment.repo';
import { PageHistoryRepo } from './repos/page/page-history.repo'; import { PageHistoryRepo } from './repos/page/page-history.repo';
import { AttachmentRepo } from './repos/attachment/attachment.repo'; import { AttachmentRepo } from './repos/attachment/attachment.repo';
@@ -71,6 +72,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
SpaceRepo, SpaceRepo,
SpaceMemberRepo, SpaceMemberRepo,
PageRepo, PageRepo,
PagePermissionRepo,
PageHistoryRepo, PageHistoryRepo,
CommentRepo, CommentRepo,
AttachmentRepo, AttachmentRepo,
@@ -87,6 +89,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
SpaceRepo, SpaceRepo,
SpaceMemberRepo, SpaceMemberRepo,
PageRepo, PageRepo,
PagePermissionRepo,
PageHistoryRepo, PageHistoryRepo,
CommentRepo, CommentRepo,
AttachmentRepo, AttachmentRepo,
@@ -0,0 +1,22 @@
import { sql } from 'kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
export async function isPageEmbeddingsTableExists(db: KyselyDB) {
return tableExists({ db, tableName: 'page_embeddings' });
}
export async function tableExists(opts: {
db: KyselyDB;
tableName: string;
}): Promise<boolean> {
const { db, tableName } = opts;
const result = await sql<{ exists: boolean }>`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = COALESCE(current_schema(), 'public')
AND table_name = ${tableName}
) as exists
`.execute(db);
return result.rows[0]?.exists ?? false;
}
@@ -4,9 +4,11 @@ import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class PageEvent { export class PageEvent {
pageIds: string[]; pageIds: string[];
workspaceId: string;
} }
@Injectable() @Injectable()
@@ -14,36 +16,65 @@ export class PageListener {
private readonly logger = new Logger(PageListener.name); private readonly logger = new Logger(PageListener.name);
constructor( constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue, @InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {} ) {}
@OnEvent(EventName.PAGE_CREATED) @OnEvent(EventName.PAGE_CREATED)
async handlePageCreated(event: PageEvent) { async handlePageCreated(event: PageEvent) {
const { pageIds } = event; const { pageIds, workspaceId } = event;
await this.searchQueue.add(QueueJob.PAGE_CREATED, { pageIds }); if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_CREATED, {
pageIds,
});
}
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
} }
@OnEvent(EventName.PAGE_UPDATED) @OnEvent(EventName.PAGE_UPDATED)
async handlePageUpdated(event: PageEvent) { async handlePageUpdated(event: PageEvent) {
const { pageIds } = event; const { pageIds } = event;
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds }); await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
} }
@OnEvent(EventName.PAGE_DELETED) @OnEvent(EventName.PAGE_DELETED)
async handlePageDeleted(event: PageEvent) { async handlePageDeleted(event: PageEvent) {
const { pageIds } = event; const { pageIds, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds }); await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
} }
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
}
@OnEvent(EventName.PAGE_SOFT_DELETED) @OnEvent(EventName.PAGE_SOFT_DELETED)
async handlePageSoftDeleted(event: PageEvent) { async handlePageSoftDeleted(event: PageEvent) {
const { pageIds } = event; const { pageIds, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds }); await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
} }
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
pageIds,
workspaceId,
});
}
@OnEvent(EventName.PAGE_RESTORED) @OnEvent(EventName.PAGE_RESTORED)
async handlePageRestored(event: PageEvent) { async handlePageRestored(event: PageEvent) {
const { pageIds } = event; const { pageIds, workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds }); await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
} }
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
}
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
} }
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class SpaceEvent {
spaceId: string;
}
@Injectable()
export class SpaceListener {
private readonly logger = new Logger(SpaceListener.name);
constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {}
@OnEvent(EventName.SPACE_DELETED)
async handleSpaceDeleted(event: SpaceEvent) {
const { spaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
}
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
}
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
}
@@ -0,0 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { EventName } from '../../common/events/event.contants';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import { EnvironmentService } from '../../integrations/environment/environment.service';
export class WorkspaceEvent {
workspaceId: string;
}
@Injectable()
export class WorkspaceListener {
private readonly logger = new Logger(WorkspaceListener.name);
constructor(
private readonly environmentService: EnvironmentService,
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
) {}
@OnEvent(EventName.WORKSPACE_DELETED)
async handlePageDeleted(event: WorkspaceEvent) {
const { workspaceId } = event;
if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
}
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
}
isTypesense(): boolean {
return this.environmentService.getSearchDriver() === 'typesense';
}
}
@@ -0,0 +1,200 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_hierarchy')
.ifNotExists()
.addColumn('ancestor_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('descendant_id', 'uuid', (col) =>
col.notNull().references('pages.id').onDelete('cascade'),
)
.addColumn('depth', 'integer', (col) => col.notNull().defaultTo(0))
.addPrimaryKeyConstraint('page_hierarchy_pkey', [
'ancestor_id',
'descendant_id',
])
.execute();
// indexes
await db.schema
.createIndex('idx_page_hierarchy_descendant')
.ifNotExists()
.on('page_hierarchy')
.column('descendant_id')
.execute();
await db.schema
.createIndex('idx_page_hierarchy_ancestor_depth')
.ifNotExists()
.on('page_hierarchy')
.columns(['ancestor_id', 'depth'])
.execute();
await db.schema
.createIndex('idx_page_hierarchy_descendant_depth')
.ifNotExists()
.on('page_hierarchy')
.columns(['descendant_id', 'depth'])
.execute();
// rebuild function
await sql`
CREATE OR REPLACE FUNCTION rebuild_page_hierarchy()
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
TRUNCATE page_hierarchy;
WITH RECURSIVE page_tree AS (
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth
FROM pages WHERE deleted_at IS NULL
UNION ALL
SELECT pt.ancestor_id, p.id AS descendant_id, pt.depth + 1
FROM page_tree pt
JOIN pages p ON p.parent_page_id = pt.descendant_id
WHERE p.deleted_at IS NULL
)
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, descendant_id, depth FROM page_tree;
END;
$$;
`.execute(db);
// Create insert trigger function
await sql`
CREATE OR REPLACE FUNCTION page_hierarchy_after_insert()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.deleted_at IS NOT NULL THEN
RETURN NEW;
END IF;
IF NEW.parent_page_id IS NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
VALUES (NEW.id, NEW.id, 0);
ELSE
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.id, depth + 1
FROM page_hierarchy
WHERE descendant_id = NEW.parent_page_id
UNION ALL
SELECT NEW.id, NEW.id, 0;
END IF;
RETURN NEW;
END;
$$;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER page_hierarchy_after_insert_trigger
AFTER INSERT ON pages
FOR EACH ROW
EXECUTE FUNCTION page_hierarchy_after_insert();
`.execute(db);
// Create update trigger function
await sql`
CREATE OR REPLACE FUNCTION page_hierarchy_after_update()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
subtree_ids UUID[];
BEGIN
-- Only process if parent_page_id or deleted_at changed
IF OLD.parent_page_id IS NOT DISTINCT FROM NEW.parent_page_id
AND OLD.deleted_at IS NOT DISTINCT FROM NEW.deleted_at THEN
RETURN NEW;
END IF;
-- Handle soft-delete: remove from closure when deleted_at is set
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
SELECT array_agg(descendant_id) INTO subtree_ids
FROM page_hierarchy
WHERE ancestor_id = NEW.id;
DELETE FROM page_hierarchy
WHERE descendant_id = ANY(subtree_ids);
RETURN NEW;
END IF;
-- Handle restore: rebuild closure when deleted_at is cleared
IF OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL THEN
IF NEW.parent_page_id IS NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
VALUES (NEW.id, NEW.id, 0);
ELSE
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT ancestor_id, NEW.id, depth + 1
FROM page_hierarchy
WHERE descendant_id = NEW.parent_page_id
UNION ALL
SELECT NEW.id, NEW.id, 0;
END IF;
RETURN NEW;
END IF;
-- Skip if page is soft-deleted
IF NEW.deleted_at IS NOT NULL THEN
RETURN NEW;
END IF;
-- Move operation: parent changed
-- Get all descendants of the moved page (including itself)
SELECT array_agg(descendant_id) INTO subtree_ids
FROM page_hierarchy
WHERE ancestor_id = NEW.id;
-- Delete old ancestor relationships (keep internal subtree links)
DELETE FROM page_hierarchy
WHERE descendant_id = ANY(subtree_ids)
AND NOT (ancestor_id = ANY(subtree_ids));
-- Insert new ancestor relationships (if new parent exists)
IF NEW.parent_page_id IS NOT NULL THEN
INSERT INTO page_hierarchy (ancestor_id, descendant_id, depth)
SELECT
new_anc.ancestor_id,
sub.descendant_id,
new_anc.depth + sub.depth + 1
FROM page_hierarchy new_anc
CROSS JOIN page_hierarchy sub
WHERE new_anc.descendant_id = NEW.parent_page_id
AND sub.ancestor_id = NEW.id
AND sub.descendant_id = ANY(subtree_ids);
END IF;
RETURN NEW;
END;
$$;
`.execute(db);
await sql`
CREATE OR REPLACE TRIGGER page_hierarchy_after_update_trigger
AFTER UPDATE ON pages
FOR EACH ROW
EXECUTE FUNCTION page_hierarchy_after_update();
`.execute(db);
await sql`SELECT rebuild_page_hierarchy()`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_update_trigger ON pages`.execute(
db,
);
await sql`DROP TRIGGER IF EXISTS page_hierarchy_after_insert_trigger ON pages`.execute(
db,
);
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_update()`.execute(db);
await sql`DROP FUNCTION IF EXISTS page_hierarchy_after_insert()`.execute(db);
await sql`DROP FUNCTION IF EXISTS rebuild_page_hierarchy()`.execute(db);
await db.schema.dropTable('page_hierarchy').ifExists().execute();
}
@@ -0,0 +1,93 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('page_access')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_id', 'uuid', (col) =>
col.notNull().unique().references('pages.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('access_level', 'varchar', (col) => col.notNull())
.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
.createTable('page_permissions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('page_access_id', 'uuid', (col) =>
col.notNull().references('page_access.id').onDelete('cascade'),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade'),
)
.addColumn('group_id', 'uuid', (col) =>
col.references('groups.id').onDelete('cascade'),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('added_by_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()`),
)
.addUniqueConstraint('page_access_user_unique', [
'page_access_id',
'user_id',
])
.addUniqueConstraint('page_access_group_unique', [
'page_access_id',
'group_id',
])
.addCheckConstraint(
'allow_either_user_id_or_group_id_check',
sql`((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL))`,
)
.execute();
await db.schema
.createIndex('idx_page_access_workspace')
.on('page_access')
.column('workspace_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_page_access')
.on('page_permissions')
.column('page_access_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_user')
.on('page_permissions')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_page_permissions_group')
.on('page_permissions')
.column('group_id')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('page_permissions').ifExists().execute();
await db.schema.dropTable('page_access').ifExists().execute();
}
@@ -152,4 +152,14 @@ export class GroupUserRepo {
.where('groupId', '=', groupId) .where('groupId', '=', groupId)
.execute(); .execute();
} }
async getUserGroupIds(userId: string): Promise<string[]> {
const results = await this.db
.selectFrom('groupUsers')
.select('groupId')
.where('userId', '=', userId)
.execute();
return results.map((r) => r.groupId);
}
} }
@@ -0,0 +1,692 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertablePageAccess,
InsertablePagePermission,
PageAccess,
PagePermission,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { sql } from 'kysely';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
@Injectable()
export class PagePermissionRepo {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
private readonly groupUserRepo: GroupUserRepo,
) {}
async findPageAccessByPageId(
pageId: string,
trx?: KyselyTransaction,
): Promise<PageAccess | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pageAccess')
.selectAll()
.where('pageId', '=', pageId)
.executeTakeFirst();
}
async insertPageAccess(
data: InsertablePageAccess,
trx?: KyselyTransaction,
): Promise<PageAccess> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('pageAccess')
.values(data)
.returningAll()
.executeTakeFirst();
}
async deletePageAccess(pageId: string, trx?: KyselyTransaction): Promise<void> {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('pageAccess').where('pageId', '=', pageId).execute();
}
async insertPagePermissions(
permissions: InsertablePagePermission[],
trx?: KyselyTransaction,
): Promise<void> {
if (permissions.length === 0) return;
const db = dbOrTx(this.db, trx);
await db
.insertInto('pagePermissions')
.values(permissions)
.execute();
}
async findPagePermissionByUserId(
pageAccessId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<PagePermission | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pagePermissions')
.selectAll()
.where('pageAccessId', '=', pageAccessId)
.where('userId', '=', userId)
.executeTakeFirst();
}
async findPagePermissionByGroupId(
pageAccessId: string,
groupId: string,
trx?: KyselyTransaction,
): Promise<PagePermission | undefined> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('pagePermissions')
.selectAll()
.where('pageAccessId', '=', pageAccessId)
.where('groupId', '=', groupId)
.executeTakeFirst();
}
async deletePagePermissionByUserId(
pageAccessId: string,
userId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('userId', '=', userId)
.execute();
}
async deletePagePermissionByGroupId(
pageAccessId: string,
groupId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('pagePermissions')
.where('pageAccessId', '=', pageAccessId)
.where('groupId', '=', groupId)
.execute();
}
async updatePagePermissionRole(
pageAccessId: string,
role: string,
opts: { userId?: string; groupId?: string },
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
let query = db
.updateTable('pagePermissions')
.set({ role, updatedAt: new Date() })
.where('pageAccessId', '=', pageAccessId);
if (opts.userId) {
query = query.where('userId', '=', opts.userId);
} else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId);
}
await query.execute();
}
async countWritersByPageAccessId(
pageAccessId: string,
trx?: KyselyTransaction,
): Promise<number> {
const db = dbOrTx(this.db, trx);
const result = await db
.selectFrom('pagePermissions')
.select((eb) => eb.fn.count('id').as('count'))
.where('pageAccessId', '=', pageAccessId)
.where('role', '=', 'writer')
.executeTakeFirst();
return Number(result?.count ?? 0);
}
async getPagePermissionsPaginated(
pageAccessId: string,
pagination: PaginationOptions,
) {
let query = this.db
.selectFrom('pagePermissions')
.leftJoin('users', 'users.id', 'pagePermissions.userId')
.leftJoin('groups', 'groups.id', 'pagePermissions.groupId')
.select([
'pagePermissions.id',
'pagePermissions.role',
'pagePermissions.createdAt',
'users.id as userId',
'users.name as userName',
'users.avatarUrl as userAvatarUrl',
'users.email as userEmail',
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.where('pageAccessId', '=', pageAccessId)
.orderBy((eb) => eb('groups.id', 'is not', null), 'desc')
.orderBy('pagePermissions.createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb(
sql`f_unaccent(users.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
)
.or(
sql`users.email`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
)
.or(
sql`f_unaccent(groups.name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
const result = await executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
const members = result.items.map((member) => {
if (member.userId) {
return {
id: member.userId,
name: member.userName,
email: member.userEmail,
avatarUrl: member.userAvatarUrl,
type: 'user' as const,
role: member.role,
createdAt: member.createdAt,
};
} else {
return {
id: member.groupId,
name: member.groupName,
memberCount: member.memberCount as number,
isDefault: member.groupIsDefault,
type: 'group' as const,
role: member.role,
createdAt: member.createdAt,
};
}
});
result.items = members as any;
return result;
}
async getUserPagePermission(
userId: string,
pageId: string,
): Promise<{ role: string } | undefined> {
const result = await this.db
.selectFrom('pageAccess')
.innerJoin('pagePermissions', 'pagePermissions.pageAccessId', 'pageAccess.id')
.select(['pagePermissions.role'])
.where('pageAccess.pageId', '=', pageId)
.where('pagePermissions.userId', '=', userId)
.unionAll(
this.db
.selectFrom('pageAccess')
.innerJoin('pagePermissions', 'pagePermissions.pageAccessId', 'pageAccess.id')
.innerJoin('groupUsers', 'groupUsers.groupId', 'pagePermissions.groupId')
.select(['pagePermissions.role'])
.where('pageAccess.pageId', '=', pageId)
.where('groupUsers.userId', '=', userId),
)
.executeTakeFirst();
return result;
}
async findRestrictedAncestor(
pageId: string,
): Promise<{ pageId: string; accessLevel: string; depth: number } | undefined> {
return this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.select([
'pageAccess.pageId',
'pageAccess.accessLevel',
'pageHierarchy.depth',
])
.where('pageHierarchy.descendantId', '=', pageId)
.orderBy('pageHierarchy.depth', 'asc')
.executeTakeFirst();
}
/**
* Check if user can access a page by verifying they have permission on ALL restricted ancestors.
*/
async canUserAccessPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageHierarchy.descendantId', '=', pageId)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
}
/**
* Check if user can edit a page by verifying they have WRITER permission on ALL restricted ancestors.
*/
async canUserEditPage(userId: string, pageId: string): Promise<boolean> {
const deniedAncestor = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageHierarchy.descendantId', '=', pageId)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !deniedAncestor;
}
/**
* Get user's access level for a page, checking ALL restricted ancestors.
* Returns:
* - hasRestriction: whether page or any ancestor has restrictions
* - canAccess: user has permission on all restricted ancestors (always true if no restrictions)
* - canEdit: user has writer permission on all restricted ancestors (always true if no restrictions)
*/
async getUserPageAccessLevel(
userId: string,
pageId: string,
): Promise<{ hasRestriction: boolean; canAccess: boolean; canEdit: boolean }> {
const result = await this.db
.selectFrom('pages')
.select((eb) => [
// hasRestriction: any ancestor has page_access entry
eb
.case()
.when(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.select('pageAccess.id')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id'),
),
)
.then(true)
.else(false)
.end()
.as('hasRestriction'),
// canAccess: no restricted ancestor without ANY permission
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canAccess'),
// canEdit: no restricted ancestor without WRITER permission
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
])
.where('pages.id', '=', pageId)
.executeTakeFirst();
return {
hasRestriction: Boolean(result?.hasRestriction),
canAccess: Boolean(result?.canAccess),
canEdit: Boolean(result?.canEdit),
};
}
/**
* Filter a list of page IDs to only those the user can access.
* Returns page IDs with their permission level (canEdit).
* Single query implementation for efficiency.
*/
async filterAccessiblePageIdsWithPermissions(
pageIds: string[],
userId: string,
): Promise<Array<{ id: string; canEdit: boolean }>> {
if (pageIds.length === 0) return [];
const results = await this.db
.selectFrom('pages')
.select('pages.id')
// Check if user lacks writer permission on any restricted ancestor
.select((eb) =>
eb
.case()
.when(
eb.not(
eb.exists(
eb
.selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on('pagePermissions.role', '=', 'writer')
.on((eb2) =>
eb2.or([
eb2('pagePermissions.userId', '=', userId),
eb2(
'pagePermissions.groupId',
'in',
eb2
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.then(true)
.else(false)
.end()
.as('canEdit'),
)
.where('pages.id', 'in', pageIds)
// Filter: user must have access (any permission on all restricted ancestors)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'pages.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => ({ id: r.id, canEdit: Boolean(r.canEdit) }));
}
/**
* Check if a page or any of its ancestors has restrictions.
* Used to determine if page-level permission checks are needed.
*/
async hasRestrictedAncestor(pageId: string): Promise<boolean> {
const result = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.ancestorId')
.select('pageAccess.id')
.where('pageHierarchy.descendantId', '=', pageId)
.executeTakeFirst();
return !!result;
}
/**
* Given a list of parent page IDs, return which ones have at least one accessible child.
* Efficient batch query for sidebar hasChildren calculation.
*/
async getParentIdsWithAccessibleChildren(
parentIds: string[],
userId: string,
): Promise<string[]> {
if (parentIds.length === 0) return [];
const results = await this.db
.selectFrom('pages as child')
.select('child.parentPageId')
.distinct()
.where('child.parentPageId', 'in', parentIds)
.where('child.deletedAt', 'is', null)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('pageHierarchy')
.innerJoin(
'pageAccess',
'pageAccess.pageId',
'pageHierarchy.ancestorId',
)
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.whereRef('pageHierarchy.descendantId', '=', 'child.id')
.where('pagePermissions.id', 'is', null),
),
),
)
.execute();
return results.map((r) => r.parentPageId);
}
/**
* Check if any descendant of a page has restrictions that the user cannot access.
* Used to determine if includeSubPages can be enabled for sharing.
*/
async hasInaccessibleDescendants(
pageId: string,
userId: string,
): Promise<boolean> {
// Get all descendant page IDs (excluding the root page itself)
const descendants = await this.db
.selectFrom('pageHierarchy')
.select('descendantId')
.where('ancestorId', '=', pageId)
.where('depth', '>', 0)
.execute();
if (descendants.length === 0) {
return false;
}
const descendantIds = descendants.map((d) => d.descendantId);
// Check if any descendant has a restriction the user cannot access
const inaccessible = await this.db
.selectFrom('pageAccess')
.leftJoin('pagePermissions', (join) =>
join
.onRef('pagePermissions.pageAccessId', '=', 'pageAccess.id')
.on((eb) =>
eb.or([
eb('pagePermissions.userId', '=', userId),
eb(
'pagePermissions.groupId',
'in',
eb
.selectFrom('groupUsers')
.select('groupUsers.groupId')
.where('groupUsers.userId', '=', userId),
),
]),
),
)
.select('pageAccess.pageId')
.where('pageAccess.pageId', 'in', descendantIds)
.where('pagePermissions.id', 'is', null)
.executeTakeFirst();
return !!inaccessible;
}
/**
* Get all descendant page IDs that have restrictions (page_access entries).
* Used to filter restricted pages from public share trees.
*/
async getRestrictedDescendantIds(pageId: string): Promise<string[]> {
const results = await this.db
.selectFrom('pageHierarchy')
.innerJoin('pageAccess', 'pageAccess.pageId', 'pageHierarchy.descendantId')
.select('pageHierarchy.descendantId')
.where('pageHierarchy.ancestorId', '=', pageId)
.execute();
return results.map((r) => r.descendantId);
}
}
@@ -125,6 +125,7 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_UPDATED, { this.eventEmitter.emit(EventName.PAGE_UPDATED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId: updatePageData.workspaceId,
}); });
return result; return result;
@@ -143,6 +144,7 @@ export class PageRepo {
this.eventEmitter.emit(EventName.PAGE_CREATED, { this.eventEmitter.emit(EventName.PAGE_CREATED, {
pageIds: [result.id], pageIds: [result.id],
workspaceId: result.workspaceId,
}); });
return result; return result;
@@ -160,7 +162,11 @@ export class PageRepo {
await query.execute(); await query.execute();
} }
async removePage(pageId: string, deletedById: string): Promise<void> { async removePage(
pageId: string,
deletedById: string,
workspaceId: string,
): Promise<void> {
const currentDate = new Date(); const currentDate = new Date();
const descendants = await this.db const descendants = await this.db
@@ -195,13 +201,15 @@ export class PageRepo {
await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute(); await trx.deleteFrom('shares').where('pageId', 'in', pageIds).execute();
}); });
this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, { this.eventEmitter.emit(EventName.PAGE_SOFT_DELETED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId,
}); });
} }
} }
async restorePage(pageId: string): Promise<void> { async restorePage(pageId: string, workspaceId: string): Promise<void> {
// First, check if the page being restored has a deleted parent // First, check if the page being restored has a deleted parent
const pageToRestore = await this.db const pageToRestore = await this.db
.selectFrom('pages') .selectFrom('pages')
@@ -263,6 +271,7 @@ export class PageRepo {
} }
this.eventEmitter.emit(EventName.PAGE_RESTORED, { this.eventEmitter.emit(EventName.PAGE_RESTORED, {
pageIds: pageIds, pageIds: pageIds,
workspaceId: workspaceId,
}); });
} }
@@ -12,10 +12,15 @@ import { PaginationOptions } from '../../pagination/pagination-options';
import { executeWithPagination } from '@docmost/db/pagination/pagination'; import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { validate as isValidUUID } from 'uuid'; import { validate as isValidUUID } from 'uuid';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
@Injectable() @Injectable()
export class SpaceRepo { export class SpaceRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(
@InjectKysely() private readonly db: KyselyDB,
private eventEmitter: EventEmitter2,
) {}
async findById( async findById(
spaceId: string, spaceId: string,
@@ -110,7 +115,11 @@ export class SpaceRepo {
if (pagination.query) { if (pagination.query) {
query = query.where((eb) => query = query.where((eb) =>
eb(sql`f_unaccent(name)`, 'ilike', sql`f_unaccent(${'%' + pagination.query + '%'})`).or( eb(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`, sql`f_unaccent(description)`,
'ilike', 'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`, sql`f_unaccent(${'%' + pagination.query + '%'})`,
@@ -155,5 +164,9 @@ export class SpaceRepo {
.where('id', '=', spaceId) .where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
.execute(); .execute();
this.eventEmitter.emit(EventName.SPACE_DELETED, {
spaceId,
});
} }
} }
@@ -175,4 +175,22 @@ export class WorkspaceRepo {
.returning(this.baseFields) .returning(this.baseFields)
.executeTakeFirst(); .executeTakeFirst();
} }
async updateAiSettings(
workspaceId: string,
prefKey: string,
prefValue: string | boolean,
) {
return this.db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.returning(this.baseFields)
.executeTakeFirst();
}
} }
+30
View File
@@ -197,6 +197,12 @@ export interface GroupUsers {
userId: string; userId: string;
} }
export interface PageHierarchy {
ancestorId: string;
descendantId: string;
depth: Generated<number>;
}
export interface PageHistory { export interface PageHistory {
content: Json | null; content: Json | null;
coverPhoto: string | null; coverPhoto: string | null;
@@ -360,6 +366,27 @@ export interface Workspaces {
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
} }
export interface PageAccess {
id: Generated<string>;
pageId: string;
workspaceId: string;
accessLevel: string;
creatorId: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface PagePermissions {
id: Generated<string>;
pageAccessId: string;
userId: string | null;
groupId: string | null;
role: string;
addedById: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface DB { export interface DB {
apiKeys: ApiKeys; apiKeys: ApiKeys;
attachments: Attachments; attachments: Attachments;
@@ -371,7 +398,10 @@ export interface DB {
fileTasks: FileTasks; fileTasks: FileTasks;
groups: Groups; groups: Groups;
groupUsers: GroupUsers; groupUsers: GroupUsers;
pageAccess: PageAccess;
pageHierarchy: PageHierarchy;
pageHistory: PageHistory; pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages; pages: Pages;
shares: Shares; shares: Shares;
spaceMembers: SpaceMembers; spaceMembers: SpaceMembers;
@@ -0,0 +1,53 @@
import {
ApiKeys,
Attachments,
AuthAccounts,
AuthProviders,
Backlinks,
Billing,
Comments,
FileTasks,
Groups,
GroupUsers,
PageAccess,
PageHierarchy,
PageHistory,
PagePermissions,
Pages,
Shares,
SpaceMembers,
Spaces,
UserMfa,
Users,
UserTokens,
WorkspaceInvitations,
Workspaces,
} from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
export interface DbInterface {
attachments: Attachments;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
backlinks: Backlinks;
billing: Billing;
comments: Comments;
fileTasks: FileTasks;
groups: Groups;
groupUsers: GroupUsers;
pageAccess: PageAccess;
pageHierarchy: PageHierarchy;
pageEmbeddings: PageEmbeddings;
pageHistory: PageHistory;
pagePermissions: PagePermissions;
pages: Pages;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
apiKeys: ApiKeys;
}
@@ -0,0 +1,20 @@
import { Json, Timestamp, Generated } from '@docmost/db/types/db';
// embeddings type
export interface PageEmbeddings {
id: Generated<string>;
pageId: string;
spaceId: string;
modelName: string;
modelDimensions: number;
workspaceId: string;
attachmentId: string;
embedding: number[];
chunkIndex: Generated<number>;
chunkStart: Generated<number>;
chunkLength: Generated<number>;
metadata: Generated<Json>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
}
@@ -3,6 +3,9 @@ import {
Attachments, Attachments,
Comments, Comments,
Groups, Groups,
PageAccess as _PageAccess,
PageHierarchy as _PageHierarchy,
PagePermissions as _PagePermissions,
Pages, Pages,
Spaces, Spaces,
Users, Users,
@@ -21,6 +24,7 @@ import {
UserMfa as _UserMFA, UserMfa as _UserMFA,
ApiKeys, ApiKeys,
} from './db'; } from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
// Workspace // Workspace
export type Workspace = Selectable<Workspaces>; export type Workspace = Selectable<Workspaces>;
@@ -125,3 +129,22 @@ export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;
export type ApiKey = Selectable<ApiKeys>; export type ApiKey = Selectable<ApiKeys>;
export type InsertableApiKey = Insertable<ApiKeys>; export type InsertableApiKey = Insertable<ApiKeys>;
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>; export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
// Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
export type UpdatablePageEmbedding = Updateable<Omit<PageEmbeddings, 'id'>>;
// Page Hierarchy (closure table - composite primary key)
export type PageHierarchy = Selectable<_PageHierarchy>;
export type InsertablePageHierarchy = Insertable<_PageHierarchy>;
// Page Access
export type PageAccess = Selectable<_PageAccess>;
export type InsertablePageAccess = Insertable<_PageAccess>;
export type UpdatablePageAccess = Updateable<Omit<_PageAccess, 'id'>>;
// Page Permission
export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
@@ -1,5 +1,5 @@
import { DB } from './db';
import { Kysely, Transaction } from 'kysely'; import { Kysely, Transaction } from 'kysely';
import { DbInterface } from '@docmost/db/types/db.interface';
export type KyselyDB = Kysely<DB>; export type KyselyDB = Kysely<DbInterface>;
export type KyselyTransaction = Transaction<DB>; export type KyselyTransaction = Transaction<DbInterface>;

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