Compare commits

...

27 Commits

Author SHA1 Message Date
Philipinho 17f3158a3b update aws packages 2026-05-01 20:00:20 +01:00
Philipinho b74ca00bfd sync 2026-05-01 14:57:32 +01:00
Philip Okugbe c247d4c1e3 feat(ee): PDF import (#2142)
* feat: replace pdfjs-dist with firecrawl-pdf-inspector

* use modified firecrawl-pdf-inspector

* feat: pdf import

* increase single file upload size limit

* use npm package

* sync

* update package
2026-05-01 14:56:39 +01:00
Philip Okugbe 641ce142df feat(ee): SCIM (#1347)
* SCIM - init (EE)

* accept db transaction

* sync

* Content parser support for scim+json

* patch scimmy

* sync

* return early if userIds is empty

* sync

* SCIM db table

* fixes

* scim tokens

* backfill

* feat(audit): add scim token events

* rename scim migration

* fix

* fix translation

* cleanup
2026-05-01 14:53:30 +01:00
Sarthak Chaturvedi 1d2486455f fix: prevent browser tab fallback in editor (#2123) 2026-05-01 13:58:51 +01:00
Philipinho a0aea43e25 feat(saml): allow disabling RequestedAuthnContext via env var
Adds SAML_DISABLE_REQUESTED_AUTHN_CONTEXT env var, passed through
    to the SAML strategy's disableRequestedAuthnContext option.
    Defaults to existing behavior (element sent). Set to true to omit
    the element when the IdP authenticates the user with a method that
    does not match (e.g. MFA, FIDO, passwordless), which would
    otherwise cause AADSTS75011 with Microsoft Entra ID.
2026-05-01 11:47:03 +01:00
Philip Okugbe 09c69d7a0f feat: properly preserve table width (#2143) 2026-05-01 00:49:31 +01:00
Sarthak Chaturvedi 9943e104a5 fix(i18n): Correct German column count label rendering (#2131) 2026-05-01 00:37:59 +01:00
Peter Tripp b16f1e5a55 fix: ctrl-k behavior on macOS (#2052)
* Improve cmd-k / ctrl-k behavior

Use cmd-k on macOS/iOS for search and keep ctrl-k everywhere else.

Fixes a bug where ctrl-k on macOS, which cuts to the end of the line,
was also triggering the search prompt.

* comment submit: cmd-enter (mac) / ctrl-enter (win/linux)
2026-05-01 00:36:40 +01:00
Philip Okugbe 24be90b95f fix: duplicate PDF uploads (#2139) 2026-04-29 10:01:47 +01:00
Olivier Lambert 3ecf27c6b0 fix(page-permission): make people-with-access list scroll past 4 entries (#2137)
The "People with access" list in the page share modal used
<ScrollArea mah={250}>, which caps the container height but does not
make the inner viewport scroll (no fixed height is given to the
viewport). Items beyond ~4 entries were rendered correctly but clipped
out of view.

Switches to <ScrollArea.Autosize mah={400}>, which is Mantine's
dedicated primitive for "grow with content up to a max, then scroll".

Closes #2135
2026-04-29 09:36:38 +01:00
Philipinho 980521f957 v0.80.1 2026-04-27 16:06:32 +01:00
Philipinho fe44dc92a9 sync 2026-04-27 15:51:23 +01:00
Philip Okugbe fad410ef23 chore: add undici for oidc proxy support (#2132) 2026-04-27 15:50:42 +01:00
Philipinho 15b8908b1a update postcss 2026-04-27 15:23:47 +01:00
Philipinho 8e15b22d8c package updates 2026-04-27 15:22:02 +01:00
Philipinho ec83fc82d5 fix: refactor sanitize 2026-04-27 15:16:26 +01:00
Philipinho a573acedd0 fix: local storage, and package overrides 2026-04-22 14:13:25 +01:00
Philipinho dba8e315ab override 2026-04-14 17:59:59 +01:00
Philipinho 81ae7a17a6 confirm dialog 2026-04-14 17:56:36 +01:00
Philipinho 271f855761 v0.80.0 2026-04-14 17:08:44 +01:00
Philipinho 3e6d915227 sync 2026-04-14 16:34:44 +01:00
Philip Okugbe a6a7e4370a feat(ee): PDF export api (#2112)
* feat(ee): server side PDF export

* feat: pdf export queue

* sync

* sync
2026-04-14 16:26:54 +01:00
Philip Okugbe cc00e77dfb fix: space overview favorites (#2110) 2026-04-14 02:58:24 +01:00
Philipinho 66c70c0e76 fix print 2026-04-14 00:40:17 +01:00
Philip Okugbe 0e8b3bbfb3 New Crowdin updates (#2109)
* 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)
2026-04-14 00:05:51 +01:00
Philip Okugbe a3a9f35005 fix home flickers (#2108) 2026-04-13 23:54:03 +01:00
103 changed files with 3999 additions and 1357 deletions
+3
View File
@@ -43,6 +43,9 @@ POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
# Gotenberg URL for server-side PDF export
GOTENBERG_URL=
DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.71.1",
"version": "0.80.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -31,8 +31,8 @@
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "^25.10.1",
"i18next-http-backend": "^3.0.2",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"jotai": "^2.18.1",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
@@ -42,7 +42,7 @@
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.13.0",
"mitt": "^3.0.1",
"posthog-js": "1.363.1",
"posthog-js": "1.372.2",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.18",
@@ -50,7 +50,7 @@
"react-drawio": "^1.0.7",
"react-error-boundary": "^6.1.1",
"react-helmet-async": "^3.0.0",
"react-i18next": "^16.5.8",
"react-i18next": "16.5.8",
"react-router-dom": "^7.13.1",
"semver": "^7.7.4",
"socket.io-client": "^4.8.3",
@@ -74,7 +74,7 @@
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.13.0",
"optics-ts": "^2.4.1",
"postcss": "^8.5.8",
"postcss": "^8.5.12",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
@@ -222,6 +222,8 @@
"Edit comment": "Kommentar bearbeiten",
"Delete comment": "Kommentar löschen",
"Are you sure you want to delete this comment?": "Sind Sie sicher, dass Sie diesen Kommentar löschen möchten?",
"Delete chat": "Chat löschen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"Comment created successfully": "Kommentar erfolgreich erstellt",
"Error creating comment": "Fehler beim Erstellen des Kommentars",
"Comment updated successfully": "Kommentar erfolgreich aktualisiert",
@@ -389,7 +391,7 @@
"Write anything. Enter \"/\" for commands": "Schreiben Sie etwas. Geben Sie \"/\" für Befehle ein",
"Write...": "\"Schreiben...\"",
"Column count": "Spaltenanzahl",
"{{count}} Columns": "{count, plural, one {# Spalte} other {# Spalten}}",
"{{count}} Columns": "{{count}} Spalten",
"Equal columns": "Gleich breite Spalten",
"Left sidebar": "Linke Seitenleiste",
"Right sidebar": "Rechte Seitenleiste",
@@ -739,6 +741,93 @@
"Removed page restriction": "Seitenbeschränkung entfernt",
"Added page permission": "Seitenberechtigung hinzugefügt",
"Removed page permission": "Seitenberechtigung entfernt",
"day": "Tag",
"days": "Tage",
"week": "Woche",
"weeks": "Wochen",
"month": "Monat",
"months": "Monate",
"year": "Jahr",
"years": "Jahre",
"Period": "Zeitraum",
"Fixed date": "Festes Datum",
"Indefinitely": "Unbegrenzt",
"Days": "Tage",
"Weeks": "Wochen",
"Months": "Monate",
"Years": "Jahre",
"Pick a date": "Datum auswählen",
"Maximum is {{max}} {{unit}} for this unit": "Das Maximum für diese Einheit beträgt {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "Läuft nie ab. Prüfer können die Seite jederzeit erneut verifizieren.",
"Verified": "Verifiziert",
"Review needed": "Prüfung erforderlich",
"Verification expired": "Verifizierung abgelaufen",
"Draft": "Entwurf",
"In Approval": "In Genehmigung",
"In approval": "In Genehmigung",
"Approved": "Genehmigt",
"Obsolete": "Veraltet",
"Expiring": "Läuft bald ab",
"Set up verification": "Verifizierung einrichten",
"Verify page": "Seite verifizieren",
"Page verification": "Seitenverifizierung",
"Add verification": "Verifizierung hinzufügen",
"Edit verification": "Verifizierung bearbeiten",
"Search by title": "Nach Titel suchen",
"Choose how this page should stay accurate.": "Wählen Sie aus, wie diese Seite aktuell gehalten werden soll.",
"Recurring verification": "Wiederkehrende Verifizierung",
"Verifiers re-confirm this page on a schedule.": "Prüfer bestätigen diese Seite nach einem Zeitplan erneut.",
"Re-verify on a schedule (e.g every 30 days )": "Nach einem Zeitplan erneut verifizieren (z. B. alle 30 Tage)",
"Page stays editable at all times": "Die Seite bleibt jederzeit bearbeitbar",
"Best for runbooks, FAQs, living documentation": "Am besten für Runbooks, FAQs und lebende Dokumentation geeignet",
"Approval workflow": "Genehmigungsworkflow",
"Formal document lifecycle with named approvers.": "Formaler Dokumentenlebenszyklus mit benannten Genehmigern.",
"Draft → In approval → Approved → Obsolete": "Entwurf → In Genehmigung → Genehmigt → Veraltet",
"Locked once approved, with full history": "Nach der Genehmigung gesperrt, mit vollständiger Historie",
"Designed for ISO 9001, ISO 13485, and FDA": "Entwickelt für ISO 9001, ISO 13485 und FDA",
"Best for SOPs and controlled documents": "Am besten für SOPs und kontrollierte Dokumente geeignet",
"Back": "Zurück",
"Quality management": "Qualitätsmanagement",
"Recurring": "Wiederkehrend",
"Pages move through draft, approval, and approved stages.": "Seiten durchlaufen die Phasen Entwurf, Genehmigung und Genehmigt.",
"Verifiers": "Prüfer",
"Add verifier": "Prüfer hinzufügen",
"I've reviewed this page for accuracy": "Ich habe diese Seite auf Richtigkeit geprüft",
"Set up": "Einrichten",
"Remove verification": "Verifizierung entfernen",
"Are you sure you want to remove verification from this page?": "Möchten Sie die Verifizierung wirklich von dieser Seite entfernen?",
"Assigned verifiers must periodically re-verify this page.": "Zugewiesene Prüfer müssen diese Seite regelmäßig erneut verifizieren.",
"Last verified by {{name}} {{time}} (expired)": "Zuletzt von {{name}} {{time}} verifiziert (abgelaufen)",
"The fixed expiration date has passed.": "Das feste Ablaufdatum ist überschritten.",
"Verified by {{name}} {{time}}": "Verifiziert von {{name}} {{time}}",
"Expires {{date}}": "Läuft ab am {{date}}",
"Expired {{date}}": "Abgelaufen am {{date}}",
"Mark as obsolete": "Als veraltet markieren",
"Mark obsolete": "Als veraltet markieren",
"Returned by {{name}} {{time}}": "Zurückgegeben von {{name}} {{time}}",
"No approval has been requested yet.": "Es wurde noch keine Genehmigung angefordert.",
"Submitted by {{name}} {{time}}": "Eingereicht von {{name}} {{time}}",
"Someone": "Jemand",
"Approved by {{name}} {{time}}": "Genehmigt von {{name}} {{time}}",
"This document has been marked as obsolete.": "Dieses Dokument wurde als veraltet markiert.",
"Rejection comment": "Ablehnungskommentar",
"Reason for returning this document...": "Grund für die Rückgabe dieses Dokuments...",
"Confirm rejection": "Ablehnung bestätigen",
"Submit for approval": "Zur Genehmigung einreichen",
"Reject": "Ablehnen",
"Approve": "Genehmigen",
"Re-submit for approval": "Erneut zur Genehmigung einreichen",
"Verified until": "Verifiziert bis",
"QMS": "QMS",
"Verified pages": "Verifizierte Seiten",
"Search pages...": "Seiten suchen...",
"Filter by space": "Nach Bereich filtern",
"Filter by type": "Nach Typ filtern",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> hat eine Seite verifiziert",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> hat eine Seite zu Ihrer Genehmigung eingereicht",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> hat eine Seite zur Überarbeitung zurückgegeben",
"Page verification expires soon": "Die Seitenverifizierung läuft bald ab",
"Page verification has expired": "Die Seitenverifizierung ist abgelaufen",
"Verifying your email": "Ihre E-Mail wird bestätigt",
"Please wait...": "Bitte warten...",
"Verification failed. The link may have expired.": "Überprüfung fehlgeschlagen. Der Link ist möglicherweise abgelaufen.",
@@ -222,6 +222,8 @@
"Edit comment": "Edit comment",
"Delete comment": "Delete comment",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Delete chat": "Delete chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Are you sure you want to delete '{{title}}'? This action cannot be undone.",
"Comment created successfully": "Comment created successfully",
"Error creating comment": "Error creating comment",
"Comment updated successfully": "Comment updated successfully",
@@ -606,25 +608,21 @@
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"API key": "API key",
"API key created successfully": "API key created successfully",
"API keys": "API keys",
"API management": "API management",
"Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
"Create API Key": "Create API Key",
"Custom expiration date": "Custom expiration date",
"Enter a descriptive token name": "Enter a descriptive token name",
"Expiration": "Expiration",
"Expired": "Expired",
"Expires": "Expires",
"I've saved my API key": "I've saved my API key",
"Last use": "Last Used",
"No API keys found": "No API keys found",
"No expiration": "No expiration",
"Revoke API key": "Revoke API key",
"Revoked successfully": "Revoked successfully",
"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.",
"Update API key": "Update API key",
"Update": "Update",
"Update {{credential}}": "Update {{credential}}",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace",
"Restrict API key creation to admins": "Restrict API key creation to admins",
"Only admins and owners can create new API keys. Existing member keys will continue to work.": "Only admins and owners can create new API keys. Existing member keys will continue to work.",
@@ -878,5 +876,29 @@
"Try a different search term.": "Try a different search term.",
"Try again": "Try again",
"Untitled chat": "Untitled chat",
"What can I help you with?": "What can I help you with?"
"What can I help you with?": "What can I help you with?",
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
"Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.",
"Configure your identity provider with this URL to provision users and groups.": "Configure your identity provider with this URL to provision users and groups.",
"Create {{credential}}": "Create {{credential}}",
"{{credential}} created": "{{credential}} created",
"{{credential}} created successfully": "{{credential}} created successfully",
"Created by": "Created by",
"Custom": "Custom",
"Enable SCIM": "Enable SCIM",
"Enter a descriptive name": "Enter a descriptive name",
"I've saved my {{credential}}": "I've saved my {{credential}}",
"Important": "Important",
"Make sure to copy your {{credential}} now. You won't be able to see it again!": "Make sure to copy your {{credential}} now. You won't be able to see it again!",
"Never": "Never",
"Revoke {{credential}}": "Revoke {{credential}}",
"SCIM endpoint URL": "SCIM endpoint URL",
"SCIM provisioning": "SCIM provisioning",
"SCIM takes precedence over SSO group sync while enabled.": "SCIM takes precedence over SSO group sync while enabled.",
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.": "You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
"SCIM token": "SCIM token",
"SCIM tokens": "SCIM tokens",
"This action cannot be undone. Your identity provider will stop syncing immediately.": "This action cannot be undone. Your identity provider will stop syncing immediately.",
"Toggle SCIM provisioning": "Toggle SCIM provisioning",
"Token": "Token"
}
@@ -222,6 +222,8 @@
"Edit comment": "Editar comentario",
"Delete comment": "Eliminar comentario",
"Are you sure you want to delete this comment?": "¿Está seguro de que desea eliminar este comentario?",
"Delete chat": "Eliminar chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}'? Esta acción no se puede deshacer.",
"Comment created successfully": "Comentario creado con éxito",
"Error creating comment": "Error al crear comentario",
"Comment updated successfully": "Comentario actualizado con éxito",
@@ -739,6 +741,93 @@
"Removed page restriction": "Restricción de página eliminada",
"Added page permission": "Permiso de página añadido",
"Removed page permission": "Permiso de página eliminado",
"day": "día",
"days": "días",
"week": "semana",
"weeks": "semanas",
"month": "mes",
"months": "meses",
"year": "año",
"years": "años",
"Period": "Período",
"Fixed date": "Fecha fija",
"Indefinitely": "Indefinidamente",
"Days": "Días",
"Weeks": "Semanas",
"Months": "Meses",
"Years": "Años",
"Pick a date": "Selecciona una fecha",
"Maximum is {{max}} {{unit}} for this unit": "El máximo es {{max}} {{unit}} para esta unidad",
"Never expires. Verifiers can re-verify at any time.": "Nunca caduca. Los verificadores pueden volver a verificar en cualquier momento.",
"Verified": "Verificado",
"Review needed": "Revisión necesaria",
"Verification expired": "La verificación ha caducado",
"Draft": "Borrador",
"In Approval": "En aprobación",
"In approval": "En aprobación",
"Approved": "Aprobado",
"Obsolete": "Obsoleto",
"Expiring": "Próximo a caducar",
"Set up verification": "Configurar verificación",
"Verify page": "Verificar página",
"Page verification": "Verificación de página",
"Add verification": "Añadir verificación",
"Edit verification": "Editar verificación",
"Search by title": "Buscar por título",
"Choose how this page should stay accurate.": "Elige cómo debe mantenerse precisa esta página.",
"Recurring verification": "Verificación periódica",
"Verifiers re-confirm this page on a schedule.": "Los verificadores vuelven a confirmar esta página según una programación.",
"Re-verify on a schedule (e.g every 30 days )": "Volver a verificar según una programación (p. ej., cada 30 días)",
"Page stays editable at all times": "La página permanece editable en todo momento",
"Best for runbooks, FAQs, living documentation": "Ideal para runbooks, preguntas frecuentes y documentación viva",
"Approval workflow": "Flujo de aprobación",
"Formal document lifecycle with named approvers.": "Ciclo de vida formal del documento con aprobadores designados.",
"Draft → In approval → Approved → Obsolete": "Borrador → En aprobación → Aprobado → Obsoleto",
"Locked once approved, with full history": "Bloqueado una vez aprobado, con historial completo",
"Designed for ISO 9001, ISO 13485, and FDA": "Diseñado para ISO 9001, ISO 13485 y FDA",
"Best for SOPs and controlled documents": "Ideal para SOP y documentos controlados",
"Back": "Atrás",
"Quality management": "Gestión de calidad",
"Recurring": "Periódica",
"Pages move through draft, approval, and approved stages.": "Las páginas pasan por las etapas de borrador, aprobación y aprobado.",
"Verifiers": "Verificadores",
"Add verifier": "Añadir verificador",
"I've reviewed this page for accuracy": "He revisado la exactitud de esta página",
"Set up": "Configurar",
"Remove verification": "Eliminar verificación",
"Are you sure you want to remove verification from this page?": "¿Seguro que quieres eliminar la verificación de esta página?",
"Assigned verifiers must periodically re-verify this page.": "Los verificadores asignados deben volver a verificar esta página periódicamente.",
"Last verified by {{name}} {{time}} (expired)": "Última verificación por {{name}} {{time}} (caducada)",
"The fixed expiration date has passed.": "La fecha fija de vencimiento ya pasó.",
"Verified by {{name}} {{time}}": "Verificado por {{name}} {{time}}",
"Expires {{date}}": "Caduca el {{date}}",
"Expired {{date}}": "Caducó el {{date}}",
"Mark as obsolete": "Marcar como obsoleto",
"Mark obsolete": "Marcar como obsoleto",
"Returned by {{name}} {{time}}": "Devuelto por {{name}} {{time}}",
"No approval has been requested yet.": "Aún no se ha solicitado aprobación.",
"Submitted by {{name}} {{time}}": "Enviado por {{name}} {{time}}",
"Someone": "Alguien",
"Approved by {{name}} {{time}}": "Aprobado por {{name}} {{time}}",
"This document has been marked as obsolete.": "Este documento ha sido marcado como obsoleto.",
"Rejection comment": "Comentario de rechazo",
"Reason for returning this document...": "Motivo de la devolución de este documento...",
"Confirm rejection": "Confirmar rechazo",
"Submit for approval": "Enviar para aprobación",
"Reject": "Rechazar",
"Approve": "Aprobar",
"Re-submit for approval": "Volver a enviar para aprobación",
"Verified until": "Verificado hasta",
"QMS": "SGC",
"Verified pages": "Páginas verificadas",
"Search pages...": "Buscar páginas...",
"Filter by space": "Filtrar por espacio",
"Filter by type": "Filtrar por tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verificó una página",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> envió una página para tu aprobación",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> devolvió una página para revisión",
"Page verification expires soon": "La verificación de la página caduca pronto",
"Page verification has expired": "La verificación de la página ha caducado",
"Verifying your email": "Verificando tu correo electrónico",
"Please wait...": "Por favor, espera...",
"Verification failed. The link may have expired.": "La verificación ha fallado. Es posible que el enlace haya expirado.",
@@ -222,6 +222,8 @@
"Edit comment": "Modifier le commentaire",
"Delete comment": "Supprimer le commentaire",
"Are you sure you want to delete this comment?": "Êtes-vous sûr de vouloir supprimer ce commentaire ?",
"Delete chat": "Supprimer la conversation",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer '{{title}}' ? Cette action est irréversible.",
"Comment created successfully": "Commentaire créé avec succès",
"Error creating comment": "Erreur lors de la création du commentaire",
"Comment updated successfully": "Commentaire mis à jour avec succès",
@@ -739,6 +741,93 @@
"Removed page restriction": "Restriction de la page supprimée",
"Added page permission": "Autorisation de la page ajoutée",
"Removed page permission": "Autorisation de la page supprimée",
"day": "jour",
"days": "jours",
"week": "semaine",
"weeks": "semaines",
"month": "mois",
"months": "mois",
"year": "an",
"years": "ans",
"Period": "Période",
"Fixed date": "Date fixe",
"Indefinitely": "Indéfiniment",
"Days": "Jours",
"Weeks": "Semaines",
"Months": "Mois",
"Years": "Ans",
"Pick a date": "Choisir une date",
"Maximum is {{max}} {{unit}} for this unit": "Le maximum est de {{max}} {{unit}} pour cette unité",
"Never expires. Verifiers can re-verify at any time.": "Nexpire jamais. Les vérificateurs peuvent revérifier à tout moment.",
"Verified": "Vérifié",
"Review needed": "Révision nécessaire",
"Verification expired": "Vérification expirée",
"Draft": "Brouillon",
"In Approval": "En approbation",
"In approval": "En approbation",
"Approved": "Approuvé",
"Obsolete": "Obsolète",
"Expiring": "Expire bientôt",
"Set up verification": "Configurer la vérification",
"Verify page": "Vérifier la page",
"Page verification": "Vérification de la page",
"Add verification": "Ajouter une vérification",
"Edit verification": "Modifier la vérification",
"Search by title": "Rechercher par titre",
"Choose how this page should stay accurate.": "Choisissez comment cette page doit rester exacte.",
"Recurring verification": "Vérification récurrente",
"Verifiers re-confirm this page on a schedule.": "Les vérificateurs reconfirment cette page selon une fréquence définie.",
"Re-verify on a schedule (e.g every 30 days )": "Revérifier selon une fréquence définie (p. ex. tous les 30 jours)",
"Page stays editable at all times": "La page reste modifiable en permanence",
"Best for runbooks, FAQs, living documentation": "Idéal pour les runbooks, FAQ et la documentation évolutive",
"Approval workflow": "Flux dapprobation",
"Formal document lifecycle with named approvers.": "Cycle de vie formel du document avec des approbateurs désignés.",
"Draft → In approval → Approved → Obsolete": "Brouillon → En approbation → Approuvé → Obsolète",
"Locked once approved, with full history": "Verrouillé une fois approuvé, avec historique complet",
"Designed for ISO 9001, ISO 13485, and FDA": "Conçu pour lISO 9001, lISO 13485 et la FDA",
"Best for SOPs and controlled documents": "Idéal pour les SOP et les documents contrôlés",
"Back": "Retour",
"Quality management": "Gestion de la qualité",
"Recurring": "Récurrent",
"Pages move through draft, approval, and approved stages.": "Les pages passent par les étapes brouillon, approbation et approuvé.",
"Verifiers": "Vérificateurs",
"Add verifier": "Ajouter un vérificateur",
"I've reviewed this page for accuracy": "Jai vérifié lexactitude de cette page",
"Set up": "Configurer",
"Remove verification": "Supprimer la vérification",
"Are you sure you want to remove verification from this page?": "Voulez-vous vraiment supprimer la vérification de cette page ?",
"Assigned verifiers must periodically re-verify this page.": "Les vérificateurs assignés doivent revérifier périodiquement cette page.",
"Last verified by {{name}} {{time}} (expired)": "Dernière vérification par {{name}} {{time}} (expirée)",
"The fixed expiration date has passed.": "La date dexpiration fixe est passée.",
"Verified by {{name}} {{time}}": "Vérifié par {{name}} {{time}}",
"Expires {{date}}": "Expire le {{date}}",
"Expired {{date}}": "Expiré le {{date}}",
"Mark as obsolete": "Marquer comme obsolète",
"Mark obsolete": "Marquer comme obsolète",
"Returned by {{name}} {{time}}": "Renvoyé par {{name}} {{time}}",
"No approval has been requested yet.": "Aucune approbation na encore été demandée.",
"Submitted by {{name}} {{time}}": "Soumis par {{name}} {{time}}",
"Someone": "Quelquun",
"Approved by {{name}} {{time}}": "Approuvé par {{name}} {{time}}",
"This document has been marked as obsolete.": "Ce document a été marqué comme obsolète.",
"Rejection comment": "Commentaire de rejet",
"Reason for returning this document...": "Raison du renvoi de ce document...",
"Confirm rejection": "Confirmer le rejet",
"Submit for approval": "Soumettre pour approbation",
"Reject": "Rejeter",
"Approve": "Approuver",
"Re-submit for approval": "Soumettre à nouveau pour approbation",
"Verified until": "Vérifié jusquau",
"QMS": "SMQ",
"Verified pages": "Pages vérifiées",
"Search pages...": "Rechercher des pages...",
"Filter by space": "Filtrer par espace",
"Filter by type": "Filtrer par type",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> a vérifié une page",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> a soumis une page à votre approbation",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> a renvoyé une page pour révision",
"Page verification expires soon": "La vérification de la page expire bientôt",
"Page verification has expired": "La vérification de la page a expiré",
"Verifying your email": "Vérification de votre e-mail",
"Please wait...": "Veuillez patienter...",
"Verification failed. The link may have expired.": "Échec de la vérification. Le lien a peut-être expiré.",
@@ -222,6 +222,8 @@
"Edit comment": "Modifica commento",
"Delete comment": "Elimina commento",
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
"Delete chat": "Elimina chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare '{{title}}'? Questa azione non può essere annullata.",
"Comment created successfully": "Commento creato con successo",
"Error creating comment": "Si è verificato un errore durante la creazione del commento",
"Comment updated successfully": "Commento aggiornato con successo",
@@ -739,6 +741,93 @@
"Removed page restriction": "Restrizione della pagina rimossa",
"Added page permission": "Permesso sulla pagina aggiunto",
"Removed page permission": "Permesso sulla pagina rimosso",
"day": "giorno",
"days": "giorni",
"week": "settimana",
"weeks": "settimane",
"month": "mese",
"months": "mesi",
"year": "anno",
"years": "anni",
"Period": "Periodo",
"Fixed date": "Data fissa",
"Indefinitely": "A tempo indeterminato",
"Days": "Giorni",
"Weeks": "Settimane",
"Months": "Mesi",
"Years": "Anni",
"Pick a date": "Scegli una data",
"Maximum is {{max}} {{unit}} for this unit": "Il massimo consentito è {{max}} {{unit}} per questa unità",
"Never expires. Verifiers can re-verify at any time.": "Non scade mai. I verificatori possono verificare nuovamente in qualsiasi momento.",
"Verified": "Verificato",
"Review needed": "Revisione necessaria",
"Verification expired": "Verifica scaduta",
"Draft": "Bozza",
"In Approval": "In approvazione",
"In approval": "In approvazione",
"Approved": "Approvato",
"Obsolete": "Obsoleto",
"Expiring": "In scadenza",
"Set up verification": "Configura la verifica",
"Verify page": "Verifica la pagina",
"Page verification": "Verifica della pagina",
"Add verification": "Aggiungi verifica",
"Edit verification": "Modifica verifica",
"Search by title": "Cerca per titolo",
"Choose how this page should stay accurate.": "Scegli come mantenere accurata questa pagina.",
"Recurring verification": "Verifica ricorrente",
"Verifiers re-confirm this page on a schedule.": "I verificatori riconfermano questa pagina secondo una pianificazione.",
"Re-verify on a schedule (e.g every 30 days )": "Verifica nuovamente secondo una pianificazione (ad es. ogni 30 giorni)",
"Page stays editable at all times": "La pagina resta sempre modificabile",
"Best for runbooks, FAQs, living documentation": "Ideale per runbook, FAQ e documentazione dinamica",
"Approval workflow": "Flusso di approvazione",
"Formal document lifecycle with named approvers.": "Ciclo di vita formale del documento con approvatori nominati.",
"Draft → In approval → Approved → Obsolete": "Bozza → In approvazione → Approvato → Obsoleto",
"Locked once approved, with full history": "Bloccato una volta approvato, con cronologia completa",
"Designed for ISO 9001, ISO 13485, and FDA": "Progettato per ISO 9001, ISO 13485 e FDA",
"Best for SOPs and controlled documents": "Ideale per SOP e documenti controllati",
"Back": "Indietro",
"Quality management": "Gestione della qualità",
"Recurring": "Ricorrente",
"Pages move through draft, approval, and approved stages.": "Le pagine passano attraverso le fasi di bozza, approvazione e approvato.",
"Verifiers": "Verificatori",
"Add verifier": "Aggiungi verificatore",
"I've reviewed this page for accuracy": "Ho controllato l'accuratezza di questa pagina",
"Set up": "Configura",
"Remove verification": "Rimuovi verifica",
"Are you sure you want to remove verification from this page?": "Sei sicuro di voler rimuovere la verifica da questa pagina?",
"Assigned verifiers must periodically re-verify this page.": "I verificatori assegnati devono verificare nuovamente questa pagina periodicamente.",
"Last verified by {{name}} {{time}} (expired)": "Ultima verifica effettuata da {{name}} {{time}} (scaduta)",
"The fixed expiration date has passed.": "La data di scadenza fissa è trascorsa.",
"Verified by {{name}} {{time}}": "Verificato da {{name}} {{time}}",
"Expires {{date}}": "Scade il {{date}}",
"Expired {{date}}": "Scaduto il {{date}}",
"Mark as obsolete": "Contrassegna come obsoleto",
"Mark obsolete": "Contrassegna come obsoleto",
"Returned by {{name}} {{time}}": "Restituito da {{name}} {{time}}",
"No approval has been requested yet.": "Non è stata ancora richiesta alcuna approvazione.",
"Submitted by {{name}} {{time}}": "Inviato da {{name}} {{time}}",
"Someone": "Qualcuno",
"Approved by {{name}} {{time}}": "Approvato da {{name}} {{time}}",
"This document has been marked as obsolete.": "Questo documento è stato contrassegnato come obsoleto.",
"Rejection comment": "Commento di rifiuto",
"Reason for returning this document...": "Motivo della restituzione di questo documento...",
"Confirm rejection": "Conferma rifiuto",
"Submit for approval": "Invia per approvazione",
"Reject": "Rifiuta",
"Approve": "Approva",
"Re-submit for approval": "Invia nuovamente per approvazione",
"Verified until": "Verificato fino al",
"QMS": "QMS",
"Verified pages": "Pagine verificate",
"Search pages...": "Cerca pagine...",
"Filter by space": "Filtra per spazio",
"Filter by type": "Filtra per tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> ha verificato una pagina",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> ha inviato una pagina per la tua approvazione",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> ha restituito una pagina per la revisione",
"Page verification expires soon": "La verifica della pagina scadrà presto",
"Page verification has expired": "La verifica della pagina è scaduta",
"Verifying your email": "Verifica della tua email in corso",
"Please wait...": "Attendere...",
"Verification failed. The link may have expired.": "Verifica non riuscita. Il link potrebbe essere scaduto.",
@@ -222,6 +222,8 @@
"Edit comment": "コメントを編集する",
"Delete comment": "コメントを削除する",
"Are you sure you want to delete this comment?": "このコメントを削除してもよろしいですか?",
"Delete chat": "チャットを削除",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "「{{title}}」を削除してもよろしいですか?この操作は元に戻せません。",
"Comment created successfully": "コメントを作成しました",
"Error creating comment": "コメントの作成に失敗しました",
"Comment updated successfully": "コメントを更新しました",
@@ -739,6 +741,93 @@
"Removed page restriction": "ページの制限を解除しました",
"Added page permission": "ページの権限を追加しました",
"Removed page permission": "ページの権限を削除しました",
"day": "日",
"days": "日",
"week": "週",
"weeks": "週",
"month": "か月",
"months": "か月",
"year": "年",
"years": "年",
"Period": "期間",
"Fixed date": "指定日",
"Indefinitely": "無期限",
"Days": "日",
"Weeks": "週",
"Months": "か月",
"Years": "年",
"Pick a date": "日付を選択",
"Maximum is {{max}} {{unit}} for this unit": "この単位の最大値は{{max}}{{unit}}です",
"Never expires. Verifiers can re-verify at any time.": "有効期限はありません。検証者はいつでも再検証できます。",
"Verified": "検証済み",
"Review needed": "確認が必要",
"Verification expired": "検証期限切れ",
"Draft": "下書き",
"In Approval": "承認中",
"In approval": "承認中",
"Approved": "承認済み",
"Obsolete": "廃止",
"Expiring": "期限間近",
"Set up verification": "検証を設定",
"Verify page": "ページを検証",
"Page verification": "ページ検証",
"Add verification": "検証を追加",
"Edit verification": "検証を編集",
"Search by title": "タイトルで検索",
"Choose how this page should stay accurate.": "このページの正確性をどのように維持するか選択してください。",
"Recurring verification": "定期検証",
"Verifiers re-confirm this page on a schedule.": "検証者がこのページを定期的に再確認します。",
"Re-verify on a schedule (e.g every 30 days )": "スケジュールに従って再検証(例:30日ごと)",
"Page stays editable at all times": "ページは常に編集可能です",
"Best for runbooks, FAQs, living documentation": "運用手順書、FAQ、継続的に更新されるドキュメントに最適",
"Approval workflow": "承認ワークフロー",
"Formal document lifecycle with named approvers.": "指定された承認者による正式な文書ライフサイクルです。",
"Draft → In approval → Approved → Obsolete": "下書き → 承認中 → 承認済み → 廃止",
"Locked once approved, with full history": "承認後はロックされ、完全な履歴が残ります",
"Designed for ISO 9001, ISO 13485, and FDA": "ISO 9001、ISO 13485、FDA向けに設計",
"Best for SOPs and controlled documents": "SOPや管理文書に最適",
"Back": "戻る",
"Quality management": "品質管理",
"Recurring": "定期",
"Pages move through draft, approval, and approved stages.": "ページは下書き、承認中、承認済みの各段階を進みます。",
"Verifiers": "検証者",
"Add verifier": "検証者を追加",
"I've reviewed this page for accuracy": "このページの正確性を確認しました",
"Set up": "設定",
"Remove verification": "検証を削除",
"Are you sure you want to remove verification from this page?": "このページから検証を削除してもよろしいですか?",
"Assigned verifiers must periodically re-verify this page.": "割り当てられた検証者はこのページを定期的に再検証する必要があります。",
"Last verified by {{name}} {{time}} (expired)": "最終検証者:{{name}} {{time}}(期限切れ)",
"The fixed expiration date has passed.": "指定された有効期限を過ぎています。",
"Verified by {{name}} {{time}}": "{{name}}が{{time}}に検証",
"Expires {{date}}": "有効期限:{{date}}",
"Expired {{date}}": "{{date}}に期限切れ",
"Mark as obsolete": "廃止としてマーク",
"Mark obsolete": "廃止にする",
"Returned by {{name}} {{time}}": "{{name}}が{{time}}に差し戻し",
"No approval has been requested yet.": "まだ承認は依頼されていません。",
"Submitted by {{name}} {{time}}": "{{name}}が{{time}}に提出",
"Someone": "誰か",
"Approved by {{name}} {{time}}": "{{name}}が{{time}}に承認",
"This document has been marked as obsolete.": "この文書は廃止としてマークされています。",
"Rejection comment": "差し戻しコメント",
"Reason for returning this document...": "この文書を差し戻す理由...",
"Confirm rejection": "差し戻しを確定",
"Submit for approval": "承認を申請",
"Reject": "差し戻す",
"Approve": "承認",
"Re-submit for approval": "再度承認を申請",
"Verified until": "検証有効期限",
"QMS": "QMS",
"Verified pages": "検証済みページ",
"Search pages...": "ページを検索...",
"Filter by space": "スペースで絞り込み",
"Filter by type": "タイプで絞り込み",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold>がページを検証しました",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold>があなたの承認のためにページを提出しました",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold>がページを修正のため差し戻しました",
"Page verification expires soon": "ページ検証の期限が間もなく切れます",
"Page verification has expired": "ページ検証の期限が切れています",
"Verifying your email": "メールアドレスを確認しています",
"Please wait...": "お待ちください…",
"Verification failed. The link may have expired.": "認証に失敗しました。リンクの有効期限が切れている可能性があります。",
@@ -222,6 +222,8 @@
"Edit comment": "댓글 수정",
"Delete comment": "댓글 삭제",
"Are you sure you want to delete this comment?": "이 댓글을 삭제하시겠습니까?",
"Delete chat": "채팅 삭제",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Comment created successfully": "댓글 생성 완료",
"Error creating comment": "댓글 생성 오류",
"Comment updated successfully": "댓글 업데이트 완료",
@@ -739,6 +741,93 @@
"Removed page restriction": "페이지 제한이 제거됨",
"Added page permission": "페이지 권한이 추가됨",
"Removed page permission": "페이지 권한이 제거됨",
"day": "일",
"days": "일",
"week": "주",
"weeks": "주",
"month": "개월",
"months": "개월",
"year": "년",
"years": "년",
"Period": "기간",
"Fixed date": "고정 날짜",
"Indefinitely": "무기한",
"Days": "일",
"Weeks": "주",
"Months": "개월",
"Years": "년",
"Pick a date": "날짜 선택",
"Maximum is {{max}} {{unit}} for this unit": "이 단위의 최대값은 {{max}} {{unit}}입니다",
"Never expires. Verifiers can re-verify at any time.": "만료되지 않습니다. 검증자는 언제든지 다시 검증할 수 있습니다.",
"Verified": "검증됨",
"Review needed": "검토 필요",
"Verification expired": "검증 만료됨",
"Draft": "초안",
"In Approval": "승인 진행 중",
"In approval": "승인 진행 중",
"Approved": "승인됨",
"Obsolete": "폐기됨",
"Expiring": "만료 예정",
"Set up verification": "검증 설정",
"Verify page": "페이지 검증",
"Page verification": "페이지 검증",
"Add verification": "검증 추가",
"Edit verification": "검증 편집",
"Search by title": "제목으로 검색",
"Choose how this page should stay accurate.": "이 페이지의 정확성을 유지할 방법을 선택하세요.",
"Recurring verification": "반복 검증",
"Verifiers re-confirm this page on a schedule.": "검증자가 일정에 따라 이 페이지를 다시 확인합니다.",
"Re-verify on a schedule (e.g every 30 days )": "일정에 따라 다시 검증(예: 30일마다)",
"Page stays editable at all times": "페이지는 항상 편집 가능합니다",
"Best for runbooks, FAQs, living documentation": "런북, FAQ, 살아 있는 문서에 적합",
"Approval workflow": "승인 워크플로",
"Formal document lifecycle with named approvers.": "지정된 승인자가 있는 공식 문서 수명 주기입니다.",
"Draft → In approval → Approved → Obsolete": "초안 → 승인 진행 중 → 승인됨 → 폐기됨",
"Locked once approved, with full history": "승인되면 잠기며 전체 이력이 유지됩니다",
"Designed for ISO 9001, ISO 13485, and FDA": "ISO 9001, ISO 13485 및 FDA용으로 설계됨",
"Best for SOPs and controlled documents": "SOP 및 관리 문서에 적합",
"Back": "뒤로",
"Quality management": "품질 관리",
"Recurring": "반복",
"Pages move through draft, approval, and approved stages.": "페이지는 초안, 승인, 승인됨 단계를 거칩니다.",
"Verifiers": "검증자",
"Add verifier": "검증자 추가",
"I've reviewed this page for accuracy": "이 페이지의 정확성을 검토했습니다",
"Set up": "설정",
"Remove verification": "검증 제거",
"Are you sure you want to remove verification from this page?": "이 페이지에서 검증을 제거하시겠습니까?",
"Assigned verifiers must periodically re-verify this page.": "지정된 검증자는 이 페이지를 주기적으로 다시 검증해야 합니다.",
"Last verified by {{name}} {{time}} (expired)": "마지막 검증자: {{name}} {{time}} (만료됨)",
"The fixed expiration date has passed.": "고정된 만료일이 지났습니다.",
"Verified by {{name}} {{time}}": "검증자: {{name}} {{time}}",
"Expires {{date}}": "{{date}}에 만료",
"Expired {{date}}": "{{date}}에 만료됨",
"Mark as obsolete": "폐기로 표시",
"Mark obsolete": "폐기 표시",
"Returned by {{name}} {{time}}": "반려자: {{name}} {{time}}",
"No approval has been requested yet.": "아직 승인이 요청되지 않았습니다.",
"Submitted by {{name}} {{time}}": "제출자: {{name}} {{time}}",
"Someone": "누군가",
"Approved by {{name}} {{time}}": "승인자: {{name}} {{time}}",
"This document has been marked as obsolete.": "이 문서는 폐기로 표시되었습니다.",
"Rejection comment": "반려 사유",
"Reason for returning this document...": "이 문서를 반려하는 이유...",
"Confirm rejection": "반려 확인",
"Submit for approval": "승인 요청",
"Reject": "반려",
"Approve": "승인",
"Re-submit for approval": "승인 재요청",
"Verified until": "다음까지 검증됨",
"QMS": "QMS",
"Verified pages": "검증된 페이지",
"Search pages...": "페이지 검색...",
"Filter by space": "스페이스별 필터",
"Filter by type": "유형별 필터",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold>님이 페이지를 검증했습니다",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold>님이 승인을 위해 페이지를 제출했습니다",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold>님이 수정을 위해 페이지를 반려했습니다",
"Page verification expires soon": "페이지 검토가 곧 만료됩니다",
"Page verification has expired": "페이지 검토가 만료되었습니다",
"Verifying your email": "이메일 확인 중",
"Please wait...": "잠시만 기다려 주세요...",
"Verification failed. The link may have expired.": "인증에 실패했습니다. 링크가 만료되었을 수 있습니다.",
@@ -222,6 +222,8 @@
"Edit comment": "Bewerk reactie",
"Delete comment": "Verwijder reactie",
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
"Delete chat": "Chat verwijderen",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Weet je zeker dat je '{{title}}' wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"Comment created successfully": "Reactie succesvol aangemaakt",
"Error creating comment": "Fout bij het aanmaken van reactie",
"Comment updated successfully": "Opmerking succesvol bijgewerkt",
@@ -739,6 +741,93 @@
"Removed page restriction": "Pagina-restrictie verwijderd",
"Added page permission": "Paginatoestemming toegevoegd",
"Removed page permission": "Paginatoestemming verwijderd",
"day": "dag",
"days": "dagen",
"week": "week",
"weeks": "weken",
"month": "maand",
"months": "maanden",
"year": "jaar",
"years": "jaren",
"Period": "Periode",
"Fixed date": "Vaste datum",
"Indefinitely": "Voor onbepaalde tijd",
"Days": "Dagen",
"Weeks": "Weken",
"Months": "Maanden",
"Years": "Jaren",
"Pick a date": "Kies een datum",
"Maximum is {{max}} {{unit}} for this unit": "Maximum is {{max}} {{unit}} voor deze eenheid",
"Never expires. Verifiers can re-verify at any time.": "Verloopt nooit. Verificateurs kunnen op elk moment opnieuw verifiëren.",
"Verified": "Geverifieerd",
"Review needed": "Beoordeling nodig",
"Verification expired": "Verificatie verlopen",
"Draft": "Concept",
"In Approval": "In goedkeuring",
"In approval": "In goedkeuring",
"Approved": "Goedgekeurd",
"Obsolete": "Verouderd",
"Expiring": "Verloopt binnenkort",
"Set up verification": "Verificatie instellen",
"Verify page": "Pagina verifiëren",
"Page verification": "Paginaverificatie",
"Add verification": "Verificatie toevoegen",
"Edit verification": "Verificatie bewerken",
"Search by title": "Zoeken op titel",
"Choose how this page should stay accurate.": "Kies hoe deze pagina accuraat moet blijven.",
"Recurring verification": "Terugkerende verificatie",
"Verifiers re-confirm this page on a schedule.": "Verificateurs bevestigen deze pagina opnieuw volgens een schema.",
"Re-verify on a schedule (e.g every 30 days )": "Opnieuw verifiëren volgens een schema (bijv. elke 30 dagen)",
"Page stays editable at all times": "Pagina blijft altijd bewerkbaar",
"Best for runbooks, FAQs, living documentation": "Het beste voor runbooks, veelgestelde vragen en levende documentatie",
"Approval workflow": "Goedkeuringsworkflow",
"Formal document lifecycle with named approvers.": "Formele documentlevenscyclus met benoemde goedkeurders.",
"Draft → In approval → Approved → Obsolete": "Concept → In goedkeuring → Goedgekeurd → Verouderd",
"Locked once approved, with full history": "Vergrendeld zodra goedgekeurd, met volledige geschiedenis",
"Designed for ISO 9001, ISO 13485, and FDA": "Ontworpen voor ISO 9001, ISO 13485 en FDA",
"Best for SOPs and controlled documents": "Het beste voor SOP's en beheerde documenten",
"Back": "Terug",
"Quality management": "Kwaliteitsmanagement",
"Recurring": "Terugkerend",
"Pages move through draft, approval, and approved stages.": "Pagina's doorlopen de fasen concept, goedkeuring en goedgekeurd.",
"Verifiers": "Verificateurs",
"Add verifier": "Verificateur toevoegen",
"I've reviewed this page for accuracy": "Ik heb deze pagina op nauwkeurigheid beoordeeld",
"Set up": "Instellen",
"Remove verification": "Verificatie verwijderen",
"Are you sure you want to remove verification from this page?": "Weet je zeker dat je verificatie van deze pagina wilt verwijderen?",
"Assigned verifiers must periodically re-verify this page.": "Toegewezen verificateurs moeten deze pagina periodiek opnieuw verifiëren.",
"Last verified by {{name}} {{time}} (expired)": "Laatst geverifieerd door {{name}} {{time}} (verlopen)",
"The fixed expiration date has passed.": "De vaste vervaldatum is verstreken.",
"Verified by {{name}} {{time}}": "Geverifieerd door {{name}} {{time}}",
"Expires {{date}}": "Verloopt op {{date}}",
"Expired {{date}}": "Verlopen op {{date}}",
"Mark as obsolete": "Markeren als verouderd",
"Mark obsolete": "Markeer als verouderd",
"Returned by {{name}} {{time}}": "Teruggestuurd door {{name}} {{time}}",
"No approval has been requested yet.": "Er is nog geen goedkeuring aangevraagd.",
"Submitted by {{name}} {{time}}": "Ingediend door {{name}} {{time}}",
"Someone": "Iemand",
"Approved by {{name}} {{time}}": "Goedgekeurd door {{name}} {{time}}",
"This document has been marked as obsolete.": "Dit document is als verouderd gemarkeerd.",
"Rejection comment": "Afwijzingsopmerking",
"Reason for returning this document...": "Reden om dit document terug te sturen...",
"Confirm rejection": "Afwijzing bevestigen",
"Submit for approval": "Indienen voor goedkeuring",
"Reject": "Afwijzen",
"Approve": "Goedkeuren",
"Re-submit for approval": "Opnieuw indienen voor goedkeuring",
"Verified until": "Geverifieerd tot",
"QMS": "QMS",
"Verified pages": "Geverifieerde pagina's",
"Search pages...": "Pagina's zoeken...",
"Filter by space": "Filteren op ruimte",
"Filter by type": "Filteren op type",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> heeft een pagina geverifieerd",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> heeft een pagina voor jouw goedkeuring ingediend",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> heeft een pagina teruggestuurd voor revisie",
"Page verification expires soon": "Paginaverificatie verloopt binnenkort",
"Page verification has expired": "Paginaverificatie is verlopen",
"Verifying your email": "Je e-mailadres wordt geverifieerd",
"Please wait...": "Even geduld...",
"Verification failed. The link may have expired.": "Verificatie mislukt. De link is mogelijk verlopen.",
@@ -222,6 +222,8 @@
"Edit comment": "Editar comentário",
"Delete comment": "Excluir comentário",
"Are you sure you want to delete this comment?": "Você tem certeza de que deseja excluir este comentário?",
"Delete chat": "Excluir chat",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir '{{title}}'? Esta ação não pode ser desfeita.",
"Comment created successfully": "Comentário criado com sucesso",
"Error creating comment": "Erro ao criar comentário",
"Comment updated successfully": "Comentário atualizado com sucesso",
@@ -739,6 +741,93 @@
"Removed page restriction": "Restrição de página removida",
"Added page permission": "Permissão de página adicionada",
"Removed page permission": "Permissão de página removida",
"day": "dia",
"days": "dias",
"week": "semana",
"weeks": "semanas",
"month": "mês",
"months": "meses",
"year": "ano",
"years": "anos",
"Period": "Período",
"Fixed date": "Data fixa",
"Indefinitely": "Indefinidamente",
"Days": "Dias",
"Weeks": "Semanas",
"Months": "Meses",
"Years": "Anos",
"Pick a date": "Escolha uma data",
"Maximum is {{max}} {{unit}} for this unit": "O máximo é {{max}} {{unit}} para esta unidade",
"Never expires. Verifiers can re-verify at any time.": "Nunca expira. Os verificadores podem verificar novamente a qualquer momento.",
"Verified": "Verificado",
"Review needed": "Revisão necessária",
"Verification expired": "A verificação expirou",
"Draft": "Rascunho",
"In Approval": "Em aprovação",
"In approval": "Em aprovação",
"Approved": "Aprovado",
"Obsolete": "Obsoleto",
"Expiring": "Expirando",
"Set up verification": "Configurar verificação",
"Verify page": "Verificar página",
"Page verification": "Verificação da página",
"Add verification": "Adicionar verificação",
"Edit verification": "Editar verificação",
"Search by title": "Pesquisar por título",
"Choose how this page should stay accurate.": "Escolha como esta página deve permanecer precisa.",
"Recurring verification": "Verificação recorrente",
"Verifiers re-confirm this page on a schedule.": "Os verificadores confirmam novamente esta página em uma programação definida.",
"Re-verify on a schedule (e.g every 30 days )": "Verificar novamente em uma programação definida (ex.: a cada 30 dias)",
"Page stays editable at all times": "A página permanece editável o tempo todo",
"Best for runbooks, FAQs, living documentation": "Ideal para runbooks, FAQs e documentação viva",
"Approval workflow": "Fluxo de aprovação",
"Formal document lifecycle with named approvers.": "Ciclo de vida formal do documento com aprovadores nomeados.",
"Draft → In approval → Approved → Obsolete": "Rascunho → Em aprovação → Aprovado → Obsoleto",
"Locked once approved, with full history": "Bloqueado após a aprovação, com histórico completo",
"Designed for ISO 9001, ISO 13485, and FDA": "Desenvolvido para ISO 9001, ISO 13485 e FDA",
"Best for SOPs and controlled documents": "Ideal para POPs e documentos controlados",
"Back": "Voltar",
"Quality management": "Gestão da qualidade",
"Recurring": "Recorrente",
"Pages move through draft, approval, and approved stages.": "As páginas passam pelos estágios de rascunho, aprovação e aprovado.",
"Verifiers": "Verificadores",
"Add verifier": "Adicionar verificador",
"I've reviewed this page for accuracy": "Revisei esta página quanto à precisão",
"Set up": "Configurar",
"Remove verification": "Remover verificação",
"Are you sure you want to remove verification from this page?": "Tem certeza de que deseja remover a verificação desta página?",
"Assigned verifiers must periodically re-verify this page.": "Os verificadores atribuídos devem verificar novamente esta página periodicamente.",
"Last verified by {{name}} {{time}} (expired)": "Verificado pela última vez por {{name}} {{time}} (expirado)",
"The fixed expiration date has passed.": "A data fixa de expiração já passou.",
"Verified by {{name}} {{time}}": "Verificado por {{name}} {{time}}",
"Expires {{date}}": "Expira em {{date}}",
"Expired {{date}}": "Expirou em {{date}}",
"Mark as obsolete": "Marcar como obsoleto",
"Mark obsolete": "Marcar como obsoleto",
"Returned by {{name}} {{time}}": "Devolvido por {{name}} {{time}}",
"No approval has been requested yet.": "Nenhuma aprovação foi solicitada ainda.",
"Submitted by {{name}} {{time}}": "Enviado por {{name}} {{time}}",
"Someone": "Alguém",
"Approved by {{name}} {{time}}": "Aprovado por {{name}} {{time}}",
"This document has been marked as obsolete.": "Este documento foi marcado como obsoleto.",
"Rejection comment": "Comentário de rejeição",
"Reason for returning this document...": "Motivo para devolver este documento...",
"Confirm rejection": "Confirmar rejeição",
"Submit for approval": "Enviar para aprovação",
"Reject": "Rejeitar",
"Approve": "Aprovar",
"Re-submit for approval": "Reenviar para aprovação",
"Verified until": "Verificado até",
"QMS": "SGQ",
"Verified pages": "Páginas verificadas",
"Search pages...": "Pesquisar páginas...",
"Filter by space": "Filtrar por espaço",
"Filter by type": "Filtrar por tipo",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> verificou uma página",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> enviou uma página para sua aprovação",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> devolveu uma página para revisão",
"Page verification expires soon": "A verificação da página expirará em breve",
"Page verification has expired": "A verificação da página expirou",
"Verifying your email": "Verificando seu e-mail",
"Please wait...": "Por favor, aguarde...",
"Verification failed. The link may have expired.": "Falha na verificação. O link pode ter expirado.",
@@ -222,6 +222,8 @@
"Edit comment": "Редактировать комментарий",
"Delete comment": "Удалить комментарий",
"Are you sure you want to delete this comment?": "Вы уверены, что хотите удалить этот комментарий?",
"Delete chat": "Удалить чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите удалить '{{title}}'? Это действие нельзя отменить.",
"Comment created successfully": "Комментарий успешно создан",
"Error creating comment": "Ошибка при создании комментария",
"Comment updated successfully": "Комментарий успешно обновлён",
@@ -739,6 +741,93 @@
"Removed page restriction": "Ограничение доступа к странице удалено",
"Added page permission": "Добавлено разрешение доступа к странице",
"Removed page permission": "Удалено разрешение доступа к странице",
"day": "день",
"days": "дни",
"week": "неделя",
"weeks": "недели",
"month": "месяц",
"months": "месяцы",
"year": "год",
"years": "годы",
"Period": "Период",
"Fixed date": "Фиксированная дата",
"Indefinitely": "Бессрочно",
"Days": "Дни",
"Weeks": "Недели",
"Months": "Месяцы",
"Years": "Годы",
"Pick a date": "Выберите дату",
"Maximum is {{max}} {{unit}} for this unit": "Максимум для этой единицы измерения — {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "Срок действия не истекает. Проверяющие могут повторно подтверждать в любое время.",
"Verified": "Проверено",
"Review needed": "Требуется проверка",
"Verification expired": "Срок проверки истёк",
"Draft": "Черновик",
"In Approval": "На утверждении",
"In approval": "На утверждении",
"Approved": "Утверждено",
"Obsolete": "Устарело",
"Expiring": "Истекает",
"Set up verification": "Настроить проверку",
"Verify page": "Проверить страницу",
"Page verification": "Проверка страницы",
"Add verification": "Добавить проверку",
"Edit verification": "Изменить проверку",
"Search by title": "Поиск по заголовку",
"Choose how this page should stay accurate.": "Выберите, как поддерживать актуальность этой страницы.",
"Recurring verification": "Регулярная проверка",
"Verifiers re-confirm this page on a schedule.": "Проверяющие повторно подтверждают эту страницу по расписанию.",
"Re-verify on a schedule (e.g every 30 days )": "Повторно проверять по расписанию (например, каждые 30 дней)",
"Page stays editable at all times": "Страница остаётся редактируемой в любое время",
"Best for runbooks, FAQs, living documentation": "Лучше всего подходит для инструкций, FAQ и живой документации",
"Approval workflow": "Процесс утверждения",
"Formal document lifecycle with named approvers.": "Формальный жизненный цикл документа с назначенными утверждающими.",
"Draft → In approval → Approved → Obsolete": "Черновик → На утверждении → Утверждено → Устарело",
"Locked once approved, with full history": "После утверждения блокируется, с полной историей",
"Designed for ISO 9001, ISO 13485, and FDA": "Разработано для ISO 9001, ISO 13485 и FDA",
"Best for SOPs and controlled documents": "Лучше всего подходит для СОП и контролируемых документов",
"Back": "Назад",
"Quality management": "Управление качеством",
"Recurring": "Регулярно",
"Pages move through draft, approval, and approved stages.": "Страницы проходят стадии черновика, утверждения и утверждённого состояния.",
"Verifiers": "Проверяющие",
"Add verifier": "Добавить проверяющего",
"I've reviewed this page for accuracy": "Я проверил(а) эту страницу на точность",
"Set up": "Настроить",
"Remove verification": "Удалить проверку",
"Are you sure you want to remove verification from this page?": "Вы уверены, что хотите удалить проверку с этой страницы?",
"Assigned verifiers must periodically re-verify this page.": "Назначенные проверяющие должны периодически повторно проверять эту страницу.",
"Last verified by {{name}} {{time}} (expired)": "Последняя проверка: {{name}}, {{time}} (срок истёк)",
"The fixed expiration date has passed.": "Фиксированная дата истечения срока уже прошла.",
"Verified by {{name}} {{time}}": "Проверено: {{name}}, {{time}}",
"Expires {{date}}": "Истекает {{date}}",
"Expired {{date}}": "Срок истёк {{date}}",
"Mark as obsolete": "Отметить как устаревшее",
"Mark obsolete": "Отметить как устаревшее",
"Returned by {{name}} {{time}}": "Возвращено: {{name}}, {{time}}",
"No approval has been requested yet.": "Запрос на утверждение ещё не отправлен.",
"Submitted by {{name}} {{time}}": "Отправлено: {{name}}, {{time}}",
"Someone": "Кто-то",
"Approved by {{name}} {{time}}": "Утверждено: {{name}}, {{time}}",
"This document has been marked as obsolete.": "Этот документ был отмечен как устаревший.",
"Rejection comment": "Комментарий к отклонению",
"Reason for returning this document...": "Причина возврата этого документа...",
"Confirm rejection": "Подтвердить отклонение",
"Submit for approval": "Отправить на утверждение",
"Reject": "Отклонить",
"Approve": "Утвердить",
"Re-submit for approval": "Повторно отправить на утверждение",
"Verified until": "Проверено до",
"QMS": "QMS",
"Verified pages": "Проверенные страницы",
"Search pages...": "Поиск страниц...",
"Filter by space": "Фильтр по пространству",
"Filter by type": "Фильтр по типу",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> проверил(а) страницу",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> отправил(а) страницу вам на утверждение",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> вернул(а) страницу на доработку",
"Page verification expires soon": "Срок проверки страницы скоро истекает",
"Page verification has expired": "Срок проверки страницы истёк",
"Verifying your email": "Подтверждение вашего адреса электронной почты",
"Please wait...": "Пожалуйста, подождите...",
"Verification failed. The link may have expired.": "Ошибка проверки. Ссылка могла устареть.",
@@ -222,6 +222,8 @@
"Edit comment": "Редагувати коментар",
"Delete comment": "Видалити коментар",
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
"Delete chat": "Видалити чат",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "Ви впевнені, що хочете видалити '{{title}}'? Цю дію неможливо скасувати.",
"Comment created successfully": "Коментар успішно створено",
"Error creating comment": "Помилка при створенні коментаря",
"Comment updated successfully": "Коментар успішно оновлено",
@@ -739,6 +741,93 @@
"Removed page restriction": "Обмеження сторінки видалено",
"Added page permission": "Додано дозвіл на сторінку",
"Removed page permission": "Дозвіл на сторінку видалено",
"day": "день",
"days": "дні",
"week": "тиждень",
"weeks": "тижні",
"month": "місяць",
"months": "місяці",
"year": "рік",
"years": "роки",
"Period": "Період",
"Fixed date": "Фіксована дата",
"Indefinitely": "Безстроково",
"Days": "Дні",
"Weeks": "Тижні",
"Months": "Місяці",
"Years": "Роки",
"Pick a date": "Виберіть дату",
"Maximum is {{max}} {{unit}} for this unit": "Максимум для цієї одиниці — {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "Термін дії не спливає. Верифікатори можуть повторно перевірити будь-коли.",
"Verified": "Перевірено",
"Review needed": "Потрібен перегляд",
"Verification expired": "Термін перевірки сплив",
"Draft": "Чернетка",
"In Approval": "На погодженні",
"In approval": "На погодженні",
"Approved": "Погоджено",
"Obsolete": "Застаріло",
"Expiring": "Термін дії спливає",
"Set up verification": "Налаштувати перевірку",
"Verify page": "Перевірити сторінку",
"Page verification": "Перевірка сторінки",
"Add verification": "Додати перевірку",
"Edit verification": "Редагувати перевірку",
"Search by title": "Пошук за назвою",
"Choose how this page should stay accurate.": "Виберіть, як підтримувати актуальність цієї сторінки.",
"Recurring verification": "Регулярна перевірка",
"Verifiers re-confirm this page on a schedule.": "Верифікатори повторно підтверджують цю сторінку за розкладом.",
"Re-verify on a schedule (e.g every 30 days )": "Повторно перевіряти за розкладом (наприклад, кожні 30 днів)",
"Page stays editable at all times": "Сторінка залишається доступною для редагування в будь-який час",
"Best for runbooks, FAQs, living documentation": "Найкраще підходить для runbook-ів, FAQ і живої документації",
"Approval workflow": "Процес погодження",
"Formal document lifecycle with named approvers.": "Формальний життєвий цикл документа з призначеними погоджувачами.",
"Draft → In approval → Approved → Obsolete": "Чернетка → На погодженні → Погоджено → Застаріло",
"Locked once approved, with full history": "Після погодження блокується, із повною історією",
"Designed for ISO 9001, ISO 13485, and FDA": "Призначено для ISO 9001, ISO 13485 та FDA",
"Best for SOPs and controlled documents": "Найкраще підходить для SOP і контрольованих документів",
"Back": "Назад",
"Quality management": "Управління якістю",
"Recurring": "Регулярна",
"Pages move through draft, approval, and approved stages.": "Сторінки проходять стадії чернетки, погодження та погодженого документа.",
"Verifiers": "Верифікатори",
"Add verifier": "Додати верифікатора",
"I've reviewed this page for accuracy": "Я перевірив(ла) цю сторінку на точність",
"Set up": "Налаштувати",
"Remove verification": "Видалити перевірку",
"Are you sure you want to remove verification from this page?": "Ви впевнені, що хочете видалити перевірку з цієї сторінки?",
"Assigned verifiers must periodically re-verify this page.": "Призначені верифікатори мають періодично повторно перевіряти цю сторінку.",
"Last verified by {{name}} {{time}} (expired)": "Востаннє перевірив(-ла) {{name}} {{time}} (термін дії сплив)",
"The fixed expiration date has passed.": "Фіксована дата завершення вже минула.",
"Verified by {{name}} {{time}}": "Перевірено: {{name}} {{time}}",
"Expires {{date}}": "Термін дії спливає {{date}}",
"Expired {{date}}": "Термін дії сплив {{date}}",
"Mark as obsolete": "Позначити як застаріле",
"Mark obsolete": "Позначити як застаріле",
"Returned by {{name}} {{time}}": "Повернуто: {{name}} {{time}}",
"No approval has been requested yet.": "Запит на погодження ще не було надіслано.",
"Submitted by {{name}} {{time}}": "Надіслано: {{name}} {{time}}",
"Someone": "Хтось",
"Approved by {{name}} {{time}}": "Погоджено: {{name}} {{time}}",
"This document has been marked as obsolete.": "Цей документ позначено як застарілий.",
"Rejection comment": "Коментар щодо відхилення",
"Reason for returning this document...": "Причина повернення цього документа...",
"Confirm rejection": "Підтвердити відхилення",
"Submit for approval": "Надіслати на погодження",
"Reject": "Відхилити",
"Approve": "Погодити",
"Re-submit for approval": "Повторно надіслати на погодження",
"Verified until": "Перевірено до",
"QMS": "QMS",
"Verified pages": "Перевірені сторінки",
"Search pages...": "Шукати сторінки...",
"Filter by space": "Фільтрувати за простором",
"Filter by type": "Фільтрувати за типом",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> перевірив(-ла) сторінку",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> надіслав(-ла) сторінку вам на погодження",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> повернув(-ла) сторінку на доопрацювання",
"Page verification expires soon": "Термін перевірки сторінки скоро спливає",
"Page verification has expired": "Термін перевірки сторінки сплив",
"Verifying your email": "Перевірка вашої електронної пошти",
"Please wait...": "Будь ласка, зачекайте...",
"Verification failed. The link may have expired.": "Підтвердження не вдалося. Посилання могло втратити чинність.",
@@ -222,6 +222,8 @@
"Edit comment": "编辑评论",
"Delete comment": "删除评论",
"Are you sure you want to delete this comment?": "你确定要删除这条评论吗?",
"Delete chat": "删除聊天",
"Are you sure you want to delete '{{title}}'? This action cannot be undone.": "您确定要删除「{{title}}」吗?此操作无法撤销。",
"Comment created successfully": "成功创建评论",
"Error creating comment": "创建评论时出错",
"Comment updated successfully": "评论更新成功",
@@ -739,6 +741,93 @@
"Removed page restriction": "已移除页面限制",
"Added page permission": "已添加页面权限",
"Removed page permission": "已移除页面权限",
"day": "天",
"days": "天",
"week": "周",
"weeks": "周",
"month": "个月",
"months": "个月",
"year": "年",
"years": "年",
"Period": "周期",
"Fixed date": "固定日期",
"Indefinitely": "无限期",
"Days": "天",
"Weeks": "周",
"Months": "个月",
"Years": "年",
"Pick a date": "选择日期",
"Maximum is {{max}} {{unit}} for this unit": "此单位的最大值为 {{max}} {{unit}}",
"Never expires. Verifiers can re-verify at any time.": "永不过期。验证者可随时重新验证。",
"Verified": "已验证",
"Review needed": "需要审核",
"Verification expired": "验证已过期",
"Draft": "草稿",
"In Approval": "审批中",
"In approval": "审批中",
"Approved": "已批准",
"Obsolete": "已作废",
"Expiring": "即将过期",
"Set up verification": "设置验证",
"Verify page": "验证页面",
"Page verification": "页面验证",
"Add verification": "添加验证",
"Edit verification": "编辑验证",
"Search by title": "按标题搜索",
"Choose how this page should stay accurate.": "选择此页面保持准确的方式。",
"Recurring verification": "定期验证",
"Verifiers re-confirm this page on a schedule.": "验证者按计划重新确认此页面。",
"Re-verify on a schedule (e.g every 30 days )": "按计划重新验证(例如每 30 天一次)",
"Page stays editable at all times": "页面始终可编辑",
"Best for runbooks, FAQs, living documentation": "最适合运行手册、常见问题和动态文档",
"Approval workflow": "审批工作流",
"Formal document lifecycle with named approvers.": "具有指定审批人的正式文档生命周期。",
"Draft → In approval → Approved → Obsolete": "草稿 → 审批中 → 已批准 → 已作废",
"Locked once approved, with full history": "批准后锁定,并保留完整历史记录",
"Designed for ISO 9001, ISO 13485, and FDA": "专为 ISO 9001、ISO 13485 和 FDA 设计",
"Best for SOPs and controlled documents": "最适合 SOP 和受控文档",
"Back": "返回",
"Quality management": "质量管理",
"Recurring": "定期",
"Pages move through draft, approval, and approved stages.": "页面会经历草稿、审批中和已批准阶段。",
"Verifiers": "验证者",
"Add verifier": "添加验证者",
"I've reviewed this page for accuracy": "我已审核此页面的准确性",
"Set up": "设置",
"Remove verification": "移除验证",
"Are you sure you want to remove verification from this page?": "确定要移除此页面的验证吗?",
"Assigned verifiers must periodically re-verify this page.": "指定的验证者必须定期重新验证此页面。",
"Last verified by {{name}} {{time}} (expired)": "最后由 {{name}} 于 {{time}} 验证(已过期)",
"The fixed expiration date has passed.": "固定到期日已过。",
"Verified by {{name}} {{time}}": "由 {{name}} 于 {{time}} 验证",
"Expires {{date}}": "于 {{date}} 到期",
"Expired {{date}}": "已于 {{date}} 过期",
"Mark as obsolete": "标记为作废",
"Mark obsolete": "标记作废",
"Returned by {{name}} {{time}}": "由 {{name}} 于 {{time}} 退回",
"No approval has been requested yet.": "尚未请求审批。",
"Submitted by {{name}} {{time}}": "由 {{name}} 于 {{time}} 提交",
"Someone": "某人",
"Approved by {{name}} {{time}}": "由 {{name}} 于 {{time}} 批准",
"This document has been marked as obsolete.": "此文档已被标记为作废。",
"Rejection comment": "退回意见",
"Reason for returning this document...": "退回此文档的原因...",
"Confirm rejection": "确认退回",
"Submit for approval": "提交审批",
"Reject": "退回",
"Approve": "批准",
"Re-submit for approval": "重新提交审批",
"Verified until": "验证有效期至",
"QMS": "QMS",
"Verified pages": "已验证页面",
"Search pages...": "搜索页面...",
"Filter by space": "按空间筛选",
"Filter by type": "按类型筛选",
"<bold>{{name}}</bold> verified a page": "<bold>{{name}}</bold> 验证了一个页面",
"<bold>{{name}}</bold> submitted a page for your approval": "<bold>{{name}}</bold> 提交了一个页面供您审批",
"<bold>{{name}}</bold> returned a page for revision": "<bold>{{name}}</bold> 退回了一个页面以供修改",
"Page verification expires soon": "页面验证即将过期",
"Page verification has expired": "页面验证已过期",
"Verifying your email": "正在验证您的邮箱",
"Please wait...": "请稍候……",
"Verification failed. The link may have expired.": "验证失败。该链接可能已过期。",
+2
View File
@@ -26,6 +26,7 @@ import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import PdfRenderPage from "@/ee/pdf-export/pdf-render-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from "@/pages/share/share-redirect.tsx";
@@ -81,6 +82,7 @@ export default function App() {
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/pdf-render/:pageId"} element={<PdfRenderPage />} />
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
@@ -116,7 +116,9 @@ export default function GlobalAppShell({
</AppShell.Navbar>
<AppShell.Main>
{isSettingsRoute ? (
<Container size={900}>{children}</Container>
<Container size={900} pb={80}>
{children}
</Container>
) : (
children
)}
@@ -33,7 +33,7 @@ export default function GlobalSidebar() {
const [active, setActive] = useState(location.pathname);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
const { data: favoriteSpacesData } = useFavoritesQuery("space");
const { data: favoriteSpacesData, isPending: isFavoritesPending } = useFavoritesQuery("space");
const favoriteSpaces = favoriteSpacesData?.pages.flatMap((p) => p.items) ?? [];
const sortedFavoriteSpaces = [...favoriteSpaces]
.filter((fav) => fav.space)
@@ -75,7 +75,7 @@ export default function GlobalSidebar() {
<Divider my="xs" />
<div className={classes.section}>
<Text className={classes.sectionHeader}>{t("Favorite spaces")}</Text>
{sortedFavoriteSpaces.length === 0 ? (
{!isFavoritesPending && sortedFavoriteSpaces.length === 0 ? (
<Text size="xs" c="dimmed" pl="xs" py={4}>
{t("Favorite spaces appear here")}
</Text>
@@ -13,6 +13,7 @@ import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
import { getAuditLogs } from "@/ee/audit/services/audit-service";
import { getVerificationList } from "@/ee/page-verification/services/page-verification-service";
import { getScimTokens } from "@/ee/scim/services/scim-token-service";
export const prefetchWorkspaceMembers = () => {
const params: QueryParams = { limit: 100, query: "" };
@@ -98,3 +99,10 @@ export const prefetchVerifiedPages = () => {
queryFn: () => getVerificationList(params),
});
};
export const prefetchScimTokens = () => {
queryClient.prefetchQuery({
queryKey: ["scim-token-list", { cursor: undefined }],
queryFn: () => getScimTokens({}),
});
};
@@ -31,6 +31,7 @@ import {
prefetchBilling,
prefetchGroups,
prefetchLicense,
prefetchScimTokens,
prefetchShares,
prefetchSpaces,
prefetchSsoProviders,
@@ -204,7 +205,10 @@ export default function SettingsSidebar() {
}
break;
case "Security & SSO":
prefetchHandler = prefetchSsoProviders;
prefetchHandler = () => {
prefetchSsoProviders();
prefetchScimTokens();
};
break;
case "Public sharing":
prefetchHandler = prefetchShares;
@@ -9,7 +9,7 @@ import classes from "../styles/chat-sidebar.module.css";
type Props = {
chat: AiChat;
isActive: boolean;
onDelete: (chatId: string) => void;
onDelete: (chatId: string, title: string | null) => void;
onRename: (chatId: string, title: string) => void;
};
@@ -153,7 +153,7 @@ export default function AiChatSidebarItem({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(chat.id);
onDelete(chat.id, chat.title);
}}
>
{t("Delete")}
@@ -1,6 +1,14 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { ActionIcon, Center, TextInput, Loader, Tooltip } from "@mantine/core";
import {
ActionIcon,
Center,
Text,
TextInput,
Loader,
Tooltip,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPlus, IconSearch, IconMessageCircle2 } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
@@ -73,16 +81,31 @@ export default function AiChatSidebar() {
);
const handleDelete = useCallback(
(id: string) => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
(id: string, title: string | null) => {
modals.openConfirmModal({
title: t("Delete chat"),
centered: true,
children: (
<Text size="sm">
{t("Are you sure you want to delete '{{title}}'? This action cannot be undone.", {
title: title || t("Untitled"),
})}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate(id, {
onSuccess: () => {
if (chatId === id) {
navigate("/ai");
}
},
});
},
});
},
[deleteMutation, chatId, navigate],
[deleteMutation, chatId, navigate, t],
);
const handleRename = useCallback(
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
title={t("{{credential}} created", { credential: t("API key") })}
size="lg"
>
<Stack gap="md">
@@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("API key") },
)}
</Alert>
@@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
{t("I've saved my {{credential}}", { credential: t("API key") })}
</Button>
</Stack>
</Modal>
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
title={t("Create {{credential}}", { credential: t("API key") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -30,12 +30,14 @@ export function RevokeApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("API key"),
})}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
title={t("Update {{credential}}", { credential: t("API key") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
@@ -33,6 +33,10 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key",
"scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token",
"space.created": "Created space",
"space.updated": "Updated space",
"space.deleted": "Deleted space",
@@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" },
],
},
{
group: "SCIM token",
items: [
{ value: "scim_token.created", label: "Created SCIM token" },
{ value: "scim_token.updated", label: "Updated SCIM token" },
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
],
},
{
group: "License",
items: [
+1
View File
@@ -8,6 +8,7 @@ export const Feature = {
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
@@ -140,7 +140,7 @@ export function PagePermissionList({
)}
</Group>
<ScrollArea mah={250} viewportRef={viewportRef}>
<ScrollArea.Autosize mah={400} viewportRef={viewportRef}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
@@ -158,7 +158,7 @@ export function PagePermissionList({
<Loader size="xs" />
</Center>
)}
</ScrollArea>
</ScrollArea.Autosize>
</>
);
}
@@ -0,0 +1,64 @@
import "@/features/editor/styles/index.css";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import ReadonlyPageEditor from "@/features/editor/readonly-page-editor";
import { Container } from "@mantine/core";
type PdfRenderData = {
pageId: string;
title: string;
content: any;
};
export default function PdfRenderPage() {
const { pageId } = useParams<{ pageId: string }>();
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const [data, setData] = useState<PdfRenderData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!pageId || !token) {
setError("Missing page ID or token");
return;
}
fetch('/api/pdf-export/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pageId, token }),
})
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((result) => setData(result.data))
.catch((err) => setError(err.message));
}, [pageId, token]);
useEffect(() => {
if (data?.title) {
document.title = data.title;
}
}, [data?.title]);
if (error) {
return <div>{error}</div>;
}
if (!data) {
return null;
}
return (
<Container size={900} p={0}>
<ReadonlyPageEditor
key={data.pageId}
title={data.title}
content={data.content}
pageId={data.pageId}
/>
</Container>
);
}
@@ -0,0 +1,78 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface CreateScimTokenModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IScimToken) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateScimTokenModal({
opened,
onClose,
onSuccess,
}: CreateScimTokenModalProps) {
const { t } = useTranslation();
const createMutation = useCreateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
const handleSubmit = async (data: FormValues) => {
try {
const created = await createMutation.mutateAsync({ name: data.name });
onSuccess(created);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,55 @@
import { Group, Text, Switch, Tooltip } 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 { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnableScim() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false);
const hasAccess = useHasFeature(Feature.SCIM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ isScimEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enable SCIM")}</Text>
<Text size="sm" c="dimmed">
{t(
"Automatically provision users and groups from your identity provider via SCIM.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle SCIM provisioning")}
/>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,61 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface RevokeScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function RevokeScimTokenModal({
opened,
onClose,
scimToken,
}: RevokeScimTokenModalProps) {
const { t } = useTranslation();
const revokeMutation = useRevokeScimTokenMutation();
const handleRevoke = async () => {
if (!scimToken) return;
await revokeMutation.mutateAsync({ tokenId: scimToken.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("SCIM token"),
})}{" "}
<strong>{scimToken?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Your identity provider will stop syncing immediately.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,69 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenCreatedModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function ScimTokenCreatedModal({
opened,
onClose,
scimToken,
}: ScimTokenCreatedModalProps) {
const { t } = useTranslation();
if (!scimToken) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("SCIM token") },
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("SCIM token")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimToken.token}
readOnly
/>
<CopyTextButton text={scimToken.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
</Button>
</Stack>
</Modal>
);
}
@@ -0,0 +1,130 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenTableProps {
tokens: IScimToken[];
isLoading?: boolean;
onUpdate?: (token: IScimToken) => void;
onRevoke?: (token: IScimToken) => void;
}
export function ScimTokenTable({
tokens,
isLoading,
onUpdate,
onRevoke,
}: ScimTokenTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Token")}</Table.Th>
<Table.Th>{t("Created by")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens && tokens.length > 0 ? (
tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{token.name}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ff="monospace" c="dimmed">
{token.tokenLastFour}
</Text>
</Table.Td>
{token.creator ? (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={token.creator?.avatarUrl}
name={token.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{token.creator.name}
</Text>
</Group>
</Table.Td>
) : (
<Table.Td>
<Text fz="sm" c="dimmed">
</Text>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(token)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(token)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,30 @@
import { Group, Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
export function ScimUrlPanel() {
const { t } = useTranslation();
const scimUrl = `${window.location.origin}/api/scim/v2`;
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("SCIM endpoint URL")}
</Text>
<Text size="xs" c="dimmed">
{t(
"Configure your identity provider with this URL to provision users and groups.",
)}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimUrl}
readOnly
/>
<CopyTextButton text={scimUrl} />
</Group>
</Stack>
);
}
@@ -0,0 +1,77 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function UpdateScimTokenModal({
opened,
onClose,
scimToken,
}: UpdateScimTokenModalProps) {
const { t } = useTranslation();
const updateMutation = useUpdateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
useEffect(() => {
if (opened && scimToken) {
form.setValues({ name: scimToken.name });
}
}, [opened, scimToken]);
const handleSubmit = async (data: FormValues) => {
if (!scimToken) return;
await updateMutation.mutateAsync({
tokenId: scimToken.id,
name: data.name,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types/scim-token.types";
export * from "./services/scim-token-service";
@@ -0,0 +1,96 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createScimToken,
getScimTokens,
revokeScimToken,
updateScimToken,
} from "@/ee/scim/services/scim-token-service";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetScimTokensQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IScimToken>, Error> {
return useQuery({
queryKey: ["scim-token-list", params],
queryFn: () => getScimTokens(params),
placeholderData: keepPreviousData,
});
}
export function useCreateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
mutationFn: (data) => createScimToken(data),
onSuccess: () => {
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("SCIM token"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdateScimTokenRequest>({
mutationFn: (data) => updateScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRevokeScimTokenRequest>({
mutationFn: (data) => revokeScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,34 @@
import api from "@/lib/api-client";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getScimTokens(
params?: QueryParams,
): Promise<IPagination<IScimToken>> {
const req = await api.post("/scim-tokens", { ...params });
return req.data;
}
export async function createScimToken(
data: ICreateScimTokenRequest,
): Promise<IScimToken> {
const req = await api.post<IScimToken>("/scim-tokens/create", data);
return req.data;
}
export async function updateScimToken(
data: IUpdateScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/update", data);
}
export async function revokeScimToken(
data: IRevokeScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/revoke", data);
}
@@ -0,0 +1,27 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IScimToken {
id: string;
name: string;
token?: string;
tokenLastFour: string;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
lastUsedAt: string | null;
createdAt: string;
creator?: Partial<IUser>;
}
export interface ICreateScimTokenRequest {
name: string;
}
export interface IUpdateScimTokenRequest {
tokenId: string;
name: string;
}
export interface IRevokeScimTokenRequest {
tokenId: string;
}
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
return (
<>
<Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm">
<Table.ScrollContainer minWidth={600} maxHeight={400}>
<Table verticalSpacing="sm" stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
+137 -6
View File
@@ -1,8 +1,18 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Divider, Title } from "@mantine/core";
import React from "react";
import {
Alert,
Button,
Card,
Divider,
Group,
Space,
Title,
Tooltip,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React, { useState } from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
@@ -12,16 +22,41 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query";
import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel";
import { ScimTokenTable } from "@/ee/scim/components/scim-token-table";
import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal";
import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal";
import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal";
import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal";
import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const SCIM_TOKEN_LIMIT = 5;
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasRetention = useHasFeature(Feature.RETENTION);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const hasScim = useHasFeature(Feature.SCIM);
const [workspace] = useAtom(workspaceAtom);
const isScimEnabled = workspace?.isScimEnabled ?? false;
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery(
hasScim && isScimEnabled ? { cursor } : undefined,
);
const [createOpen, setCreateOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<IScimToken | null>(null);
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
if (!isAdmin) {
return null;
@@ -45,7 +80,7 @@ export default function Security() {
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
{t("Single sign-on (SSO)")}
</Title>
<EnforceSso />
@@ -66,6 +101,102 @@ export default function Security() {
)}
<SsoProviderList />
{hasScim && (
<>
<Divider my="xl" />
<Title order={4} my="lg">
{t("SCIM provisioning")}
</Title>
<Alert
icon={<IconInfoCircle size={16} />}
color="blue"
variant="light"
mb="md"
>
{t("SCIM takes precedence over SSO group sync while enabled.")}
</Alert>
<EnableScim />
<Divider my="lg" />
<ScimUrlPanel />
{isScimEnabled && (
<>
<Divider my="lg" />
<Group justify="space-between" mb="md">
<Title order={5}>{t("SCIM tokens")}</Title>
<Tooltip
label={t(
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
>
{t("Create {{credential}}", {
credential: t("SCIM token"),
})}
</Button>
</Tooltip>
</Group>
<Card shadow="sm" radius="sm">
<ScimTokenTable
tokens={scimData?.items}
isLoading={scimLoading}
onUpdate={setUpdateTarget}
onRevoke={setRevokeTarget}
/>
</Card>
<Space h="md" />
{scimData?.items.length > 0 && (
<Paginate
hasPrevPage={scimData?.meta?.hasPrevPage}
hasNextPage={scimData?.meta?.hasNextPage}
onNext={() => goNext(scimData?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateScimTokenModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={setCreatedToken}
/>
<ScimTokenCreatedModal
opened={!!createdToken}
onClose={() => setCreatedToken(null)}
scimToken={createdToken}
/>
<UpdateScimTokenModal
opened={!!updateTarget}
onClose={() => setUpdateTarget(null)}
scimToken={updateTarget}
/>
<RevokeScimTokenModal
opened={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
scimToken={revokeTarget}
/>
</>
)}
</>
)}
</>
);
}
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
import { platformModifierKey } from "@/lib";
interface CommentEditorProps {
defaultContent?: any;
@@ -83,7 +84,7 @@ const CommentEditor = forwardRef(
}
}
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
if (platformModifierKey(event) && event.code === "Enter") {
event.preventDefault();
if (onSave) onSave();
@@ -19,7 +19,9 @@ export const uploadAttachmentAction = handleAttachmentUpload({
},
validateFn: (file, allowMedia: boolean) => {
if (
(file.type.includes("image/") || file.type.includes("video/")) &&
(file.type.includes("image/") ||
file.type.includes("video/") ||
file.type === "application/pdf") &&
!allowMedia
) {
return false;
@@ -80,10 +80,12 @@ export const MarkdownClipboard = Extension.create({
const { from, to } = view.state.selection;
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const body = elementFromString(parsed);
normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
).parseSlice(elementFromString(parsed), {
).parseSlice(body, {
preserveWhitespace: true,
});
@@ -137,3 +139,92 @@ function elementFromString(value) {
return new window.DOMParser().parseFromString(wrappedValue, "text/html").body;
}
const DEFAULT_PASTE_COL_WIDTH_PX = 150;
function parsePixelWidth(el: Element): number | null {
const attr = el.getAttribute("width");
if (attr) {
const n = parseInt(attr, 10);
if (Number.isFinite(n) && n > 0) return n;
}
const style = el.getAttribute("style") || "";
const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
if (m) {
const n = parseInt(m[1], 10);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
function getFirstRow(table: Element): Element | null {
const tbodyRow = table.querySelector(":scope > tbody > tr");
if (tbodyRow) return tbodyRow;
const theadRow = table.querySelector(":scope > thead > tr");
if (theadRow) return theadRow;
return table.querySelector(":scope > tr");
}
function deriveColumnWidths(table: Element): (number | null)[] | null {
const cols = table.querySelectorAll(":scope > colgroup > col");
if (cols.length > 0) {
const widths: (number | null)[] = [];
cols.forEach((col) => widths.push(parsePixelWidth(col)));
if (widths.some((w) => w !== null)) return widths;
}
const firstRow = getFirstRow(table);
if (!firstRow) return null;
const widths: (number | null)[] = [];
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
const w = parsePixelWidth(cell);
for (let i = 0; i < colspan; i++) {
widths.push(w !== null ? Math.round(w / colspan) : null);
}
});
if (widths.length === 0 || widths.every((w) => w === null)) return null;
return widths;
}
// Mirror of server normalizeTableColumnWidths (see import/utils/table-utils.ts):
// markdown source has no widths, so without this every pasted table renders
// at table-layout:fixed/100% and squashes columns to fit the editor instead of
// letting .tableWrapper's overflow-x: auto scroll.
export function normalizeTableColumnWidths(root: Element): void {
root.querySelectorAll("table").forEach((table) => {
const firstRow = getFirstRow(table);
if (!firstRow) return;
let colWidths = deriveColumnWidths(table);
if (!colWidths) {
let count = 0;
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
count += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
});
if (count === 0) return;
colWidths = new Array(count).fill(DEFAULT_PASTE_COL_WIDTH_PX);
}
let col = 0;
Array.from(firstRow.children)
.filter((c) => c.tagName === "TD" || c.tagName === "TH")
.forEach((cell) => {
if (cell.getAttribute("colwidth")) {
col += parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
return;
}
const colspan = parseInt(cell.getAttribute("colspan") || "1", 10) || 1;
const slice = colWidths!.slice(col, col + colspan);
col += colspan;
if (slice.length === 0 || slice.every((w) => w === null)) return;
const values = slice.map((w) => (w == null ? 100 : w));
cell.setAttribute("colwidth", values.join(","));
});
});
}
@@ -102,6 +102,7 @@ function PageByline({
<Group
gap="sm"
mb="md"
className="print-hide"
style={{ marginTop: "-0.5em", paddingLeft: "3rem" }}
>
{creator && (
@@ -62,7 +62,7 @@ import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { extractPageSlugId, platformModifierKey } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
@@ -232,11 +232,19 @@ export default function PageEditor({
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
if (event.key === "Tab") {
const editor = editorRef.current;
if (!editor) return false;
event.preventDefault();
return editor.view.someProp("handleKeyDown", (f) =>
f(editor.view, event)
);
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
@@ -27,6 +27,7 @@ import localEmitter from "@/lib/local-emitter.ts";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
export interface TitleEditorProps {
pageId: string;
@@ -90,11 +91,11 @@ export function TitleEditor({
editorProps: {
handleDOMEvents: {
keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
@@ -14,11 +14,11 @@ import {
} from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types";
export function useFavoritesQuery(type?: FavoriteType) {
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
return useInfiniteQuery({
queryKey: ["favorites", type],
queryKey: ["favorites", type, spaceId],
queryFn: ({ pageParam }) =>
getFavorites({ type, cursor: pageParam, limit: 15 }),
getFavorites({ type, spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
@@ -26,10 +26,10 @@ export function useFavoritesQuery(type?: FavoriteType) {
});
}
export function useFavoriteIds(type: FavoriteType): Set<string> {
export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set<string> {
const { data } = useQuery({
queryKey: ["favorite-ids", type],
queryFn: () => getFavoriteIds(type),
queryKey: ["favorite-ids", type, spaceId],
queryFn: () => getFavoriteIds(type, spaceId),
refetchOnMount: true,
});
@@ -52,9 +52,9 @@ export function useAddFavoriteMutation() {
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueryData(
["favorite-ids", variables.type],
(old: { items: string[]; meta: any } | undefined) => {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
if (!old) return old;
if (old.items.includes(entityId)) return old;
return { ...old, items: [...old.items, entityId] };
@@ -76,9 +76,9 @@ export function useRemoveFavoriteMutation() {
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueryData(
["favorite-ids", variables.type],
(old: { items: string[]; meta: any } | undefined) => {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
if (!old) return old;
return { ...old, items: old.items.filter((id) => id !== entityId) };
},
@@ -21,13 +21,14 @@ export async function removeFavorite(
await api.post("/favorites/remove", params);
}
export async function getFavoriteIds(type: FavoriteType): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/favorites/ids", { type });
export async function getFavoriteIds(type: FavoriteType, spaceId?: string): Promise<IPagination<string>> {
const req = await api.post<IPagination<string>>("/favorites/ids", { type, spaceId });
return req.data;
}
export async function getFavorites(params?: {
type?: FavoriteType;
spaceId?: string;
limit?: number;
cursor?: string;
}): Promise<IPagination<IFavorite>> {
@@ -18,7 +18,11 @@ import { getSpaceUrl } from "@/lib/config";
import { useTranslation } from "react-i18next";
import { getInitialsColor } from "@/lib/get-initials-color";
export default function FavoritesPages() {
interface Props {
spaceId?: string;
}
export default function FavoritesPages({ spaceId }: Props) {
const { t } = useTranslation();
const {
data,
@@ -27,7 +31,7 @@ export default function FavoritesPages() {
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useFavoritesQuery("page");
} = useFavoritesQuery("page", spaceId);
const favorites = data?.pages.flatMap((p) => p.items) ?? [];
@@ -72,19 +76,21 @@ export default function FavoritesPages() {
</Group>
</UnstyledButton>
</Table.Td>
<Table.Td>
{fav.space && (
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}
>
{fav.space.name}
</Badge>
)}
</Table.Td>
{!spaceId && (
<Table.Td>
{fav.space && (
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}
>
{fav.space.name}
</Badge>
)}
</Table.Td>
)}
<Table.Td>
<Text
c="dimmed"
@@ -145,7 +145,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const favoriteIds = useFavoriteIds("page");
const favoriteIds = useFavoriteIds("page", page?.spaceId);
const addFavoriteMutation = useAddFavoriteMutation();
const removeFavoriteMutation = useRemoveFavoriteMutation();
const isFavorited = page?.id ? favoriteIds.has(page.id) : false;
@@ -12,6 +12,7 @@ import {
IconCheck,
IconFileCode,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypeZip,
IconMarkdown,
IconX,
@@ -90,12 +91,14 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null);
const docxFileRef = useRef<() => void>(null);
const pdfFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null);
const canUseConfluence = useHasFeature(Feature.CONFLUENCE_IMPORT);
const canUseDocx = useHasFeature(Feature.DOCX_IMPORT);
const canUsePdf = useHasFeature(Feature.PDF_IMPORT);
const upgradeLabel = useUpgradeLabel();
const handleZipUpload = async (selectedFile: File, source: string) => {
@@ -244,7 +247,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
}, 3000);
}, [fileTaskId]);
const maxSingleFileSize = bytes("20mb");
const maxSingleFileSize = bytes("30mb");
const handleFileUpload = async (selectedFiles: File[]) => {
if (!selectedFiles) {
@@ -298,6 +301,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current();
if (docxFileRef.current) docxFileRef.current();
if (pdfFileRef.current) pdfFileRef.current();
const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -378,6 +382,30 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)}
</FileButton>
<FileButton
onChange={handleFileUpload}
accept=".pdf"
multiple
resetRef={pdfFileRef}
>
{(props) => (
<Tooltip
label={upgradeLabel}
disabled={canUsePdf}
>
<Button
disabled={!canUsePdf}
justify="start"
variant="default"
leftSection={<IconFileTypePdf size={18} />}
{...props}
>
PDF
</Button>
</Tooltip>
)}
</FileButton>
<FileButton
onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip"
@@ -509,7 +509,7 @@ function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const favoriteIds = useFavoriteIds("page");
const favoriteIds = useFavoriteIds("page", spaceId);
const addFavorite = useAddFavoriteMutation();
const removeFavorite = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(node.data.id);
@@ -13,6 +13,7 @@ import {
import classes from "./search-control.module.css";
import React from "react";
import { useTranslation } from "react-i18next";
import { platformModifierLabel } from "@/lib";
interface SearchControlProps extends BoxProps, ElementProps<"button"> {}
@@ -27,7 +28,7 @@ export function SearchControl({ className, ...others }: SearchControlProps) {
{t("Search")}
</Text>
<Text fw={700} className={classes.shortcut}>
Ctrl + K
{platformModifierLabel} + K
</Text>
</Group>
</UnstyledButton>
@@ -1,4 +1,4 @@
import { Text, Card, rem, Group, Button } from "@mantine/core";
import { Text, Card, rem, Group, Button, Skeleton } from "@mantine/core";
import {
prefetchSpace,
useGetSpacesQuery,
@@ -13,9 +13,37 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import CardCarousel from "@/components/ui/card-carousel";
function SpaceCardSkeleton() {
return (
<Card p="xs" radius="md" withBorder className={classes.card}>
<Card.Section className={classes.cardSection} h={40} />
<Skeleton circle height={38} width={38} mt={rem(-20)} />
<Skeleton height={14} mt="xs" width="70%" radius="xl" />
<Skeleton height={10} mt="md" width="40%" radius="xl" />
</Card>
);
}
export default function SpaceCarousel() {
const { t } = useTranslation();
const { data } = useGetSpacesQuery({ limit: 20 });
const { data, isPending } = useGetSpacesQuery({ limit: 20 });
if (isPending) {
return (
<>
<Group justify="space-between" align="center" mb="md">
<Text fz="sm" fw={500}>
{t("Spaces you belong to")}
</Text>
</Group>
<CardCarousel ariaLabel={t("Spaces you belong to")}>
{Array.from({ length: 4 }, (_, i) => (
<SpaceCardSkeleton key={i} />
))}
</CardCarousel>
</>
);
}
const cards = data?.items.map((space) => (
<Card
@@ -47,7 +47,7 @@ export default function SpaceHomeTabs() {
{space?.id && <RecentChanges spaceId={space.id} />}
</Tabs.Panel>
<Tabs.Panel value="favorites">
<FavoritesPages />
{space?.id && <FavoritesPages spaceId={space.id} />}
</Tabs.Panel>
<Tabs.Panel value="created">
{space?.id && <CreatedByMe spaceId={space.id} />}
@@ -28,6 +28,7 @@ export interface IWorkspace {
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
isScimEnabled?: boolean;
}
export interface IWorkspaceSettings {
+9
View File
@@ -100,6 +100,15 @@ export const normalizeUrl = (url: string): string => {
return `https://${url}`;
};
const _isApple = /mac|iphone|ipad|ipod/i.test(navigator.platform ?? "");
/// Cmd key on Apple devices, Ctrl key everywhere else
export function platformModifierKey(event: KeyboardEvent): boolean {
return _isApple ? event.metaKey : event.ctrlKey;
}
export const platformModifierLabel = _isApple ? "⌘" : "Ctrl";
export function castToBoolean(value: unknown): boolean {
if (value == null) {
return false;
+23 -18
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.71.1",
"version": "0.80.1",
"description": "",
"author": "",
"private": true,
@@ -33,13 +33,14 @@
"@ai-sdk/google": "^3.0.52",
"@ai-sdk/openai": "^3.0.47",
"@ai-sdk/openai-compatible": "^2.0.37",
"@aws-sdk/client-s3": "3.1014.0",
"@aws-sdk/lib-storage": "3.1014.0",
"@aws-sdk/s3-request-presigner": "3.1014.0",
"@aws-sdk/client-s3": "3.1040.0",
"@aws-sdk/lib-storage": "3.1040.0",
"@aws-sdk/s3-request-presigner": "3.1040.0",
"@clickhouse/client": "^1.18.2",
"@docmost/pdf-inspector": "1.9.4",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@keyv/redis": "^5.1.6",
"@langchain/core": "1.1.39",
"@langchain/textsplitters": "1.0.1",
@@ -48,19 +49,19 @@
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
"@nestjs/common": "^11.1.18",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.18",
"@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.1.19",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "^2.1.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-fastify": "^11.1.18",
"@nestjs/platform-socket.io": "^11.1.18",
"@nestjs/schedule": "^6.1.1",
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.18",
"@nestjs/websockets": "^11.1.19",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.10",
"@react-email/render": "2.0.4",
@@ -69,7 +70,7 @@
"ai-sdk-ollama": "^3.8.1",
"bcrypt": "^6.0.0",
"bowser": "^2.14.1",
"bullmq": "^5.71.0",
"bullmq": "^5.76.0",
"cache-manager": "^7.2.8",
"cheerio": "^1.2.0",
"class-transformer": "^0.5.1",
@@ -100,7 +101,6 @@
"p-limit": "^7.3.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pdfjs-dist": "^5.5.207",
"pg-tsquery": "^8.4.2",
"pgvector": "^0.2.1",
"pino-http": "^11.0.0",
@@ -110,22 +110,24 @@
"react": "^18.3.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sanitize-filename-ts": "1.0.2",
"sanitize-filename": "1.6.3",
"scimmy": "1.3.5",
"socket.io": "^4.8.3",
"stripe": "^17.7.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3",
"tseep": "^1.3.1",
"typesense": "^3.0.5",
"undici": "7.24.0",
"ws": "^8.20.0",
"yauzl": "^3.2.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.28.0",
"@nestjs/cli": "^11.0.18",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.0.10",
"@nestjs/testing": "^11.1.18",
"@nestjs/testing": "^11.1.19",
"@types/bcrypt": "^6.0.0",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
@@ -165,6 +167,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked)(@|/))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
],
@@ -23,6 +23,11 @@ export const AuditEvent = {
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
// SCIM Tokens
SCIM_TOKEN_CREATED: 'scim_token.created',
SCIM_TOKEN_UPDATED: 'scim_token.updated',
SCIM_TOKEN_DELETED: 'scim_token.deleted',
// Space
SPACE_CREATED: 'space.created',
SPACE_UPDATED: 'space.updated',
@@ -119,6 +124,7 @@ export const AuditResource = {
COMMENT: 'comment',
SHARE: 'share',
API_KEY: 'api_key',
SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation',
ATTACHMENT: 'attachment',
+2
View File
@@ -8,6 +8,7 @@ export const Feature = {
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
@@ -18,6 +19,7 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
Binary file not shown.
+29 -7
View File
@@ -1,6 +1,6 @@
import * as path from 'path';
import * as bcrypt from 'bcrypt';
import { sanitize } from 'sanitize-filename-ts';
import sanitize = require('sanitize-filename');
import { FastifyRequest } from 'fastify';
import { Readable, Transform } from 'stream';
@@ -72,11 +72,33 @@ export function extractDateFromUuid7(uuid7: string) {
return new Date(timestamp);
}
export function sanitizeFileName(fileName: string): string {
const sanitizedFilename = sanitize(fileName)
.replace(/ /g, '_')
.replace(/#/g, '_');
return sanitizedFilename.slice(0, 255);
export type SanitizeFileNameOptions = {
/** Keep spaces and `#` instead of replacing them with `_`. Useful for
* download filenames where readability matters. Defaults to false. */
preserveSpaces?: boolean;
};
export function sanitizeFileName(
fileName: string,
options: SanitizeFileNameOptions = {},
): string {
// Decode percent-encoded sequences so that bypasses like "..%2F" reach
// sanitize() as literal "../" and get stripped. sanitize-filename only
// strips literal characters and won't catch encoded path separators
// on its own.
const decoded = fileName.replace(/%[0-9a-fA-F]{2}/g, (m) => {
try {
return decodeURIComponent(m);
} catch {
return m;
}
});
const sanitized = sanitize(decoded);
if (options.preserveSpaces) {
return sanitized;
}
return sanitized.replace(/ /g, '_').replace(/#/g, '_');
}
export function removeAccent(str: string): string {
@@ -88,7 +110,7 @@ export function extractBearerTokenFromHeader(
request: FastifyRequest,
): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
return type?.toLowerCase() === 'bearer' ? token : undefined;
}
/**
@@ -356,9 +356,19 @@ export class AttachmentController {
throw new BadRequestException('Invalid image attachment type');
}
const filenameWithoutExt = path.basename(fileName, path.extname(fileName));
if (!isValidUUID(filenameWithoutExt)) {
throw new BadRequestException('Invalid file id');
if (!fileName) {
throw new BadRequestException('Invalid file name');
}
const ext = path.extname(fileName);
const filenameWithoutExt = path.basename(fileName, ext);
if (
!ext ||
!isValidUUID(filenameWithoutExt) ||
`${filenameWithoutExt}${ext}` !== fileName
) {
throw new BadRequestException('Invalid file name');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
@@ -5,6 +5,8 @@ export enum JwtType {
ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token',
API_KEY = 'api_key',
PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
}
export type JwtPayload = {
sub: string;
@@ -45,3 +47,15 @@ export type JwtApiKeyPayload = {
apiKeyId: string;
type: 'api_key';
};
export type JwtPdfRenderPayload = {
pageId: string;
workspaceId: string;
type: 'pdf_render';
};
export type JwtPdfExportDownloadPayload = {
fileTaskId: string;
workspaceId: string;
type: 'pdf_export_download';
};
@@ -13,6 +13,8 @@ import {
JwtExchangePayload,
JwtMfaTokenPayload,
JwtPayload,
JwtPdfExportDownloadPayload,
JwtPdfRenderPayload,
JwtType,
} from '../dto/jwt-payload';
import { User } from '@docmost/db/types/entity.types';
@@ -115,6 +117,30 @@ export class TokenService {
return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
}
async generatePdfRenderToken(
pageId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfRenderPayload = {
pageId,
workspaceId,
type: JwtType.PDF_RENDER,
};
return this.jwtService.sign(payload, { expiresIn: '60s' });
}
async generatePdfExportDownloadToken(
fileTaskId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtPdfExportDownloadPayload = {
fileTaskId,
workspaceId,
type: JwtType.PDF_EXPORT_DOWNLOAD,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
@@ -1,8 +1,12 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
export class FavoriteIdsDto {
@IsString()
@IsNotEmpty()
@IsIn(['page', 'space', 'template'])
type: 'page' | 'space' | 'template';
@IsOptional()
@IsUUID()
spaceId?: string;
}
@@ -1,8 +1,12 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
export class ListFavoritesDto {
@IsOptional()
@IsString()
@IsIn(['page', 'space', 'template'])
type?: 'page' | 'space' | 'template';
@IsOptional()
@IsUUID()
spaceId?: string;
}
@@ -82,6 +82,7 @@ export class FavoriteController {
user.id,
workspace.id,
dto.type as FavoriteType,
dto.spaceId,
);
}
@@ -98,6 +99,7 @@ export class FavoriteController {
workspace.id,
pagination,
dto.type as FavoriteType | undefined,
dto.spaceId,
);
}
@@ -20,11 +20,13 @@ export class FavoriteService {
userId: string,
workspaceId: string,
type: FavoriteType,
spaceId?: string,
) {
const result = await this.favoriteRepo.getFavoriteIds(
userId,
workspaceId,
type,
spaceId,
);
if (result.items.length === 0) {
@@ -95,12 +97,14 @@ export class FavoriteService {
workspaceId: string,
pagination: PaginationOptions,
type?: FavoriteType,
spaceId?: string,
) {
const result = await this.favoriteRepo.findUserFavorites(
userId,
workspaceId,
pagination,
type,
spaceId,
);
if (result.items.length === 0) {
@@ -7,7 +7,7 @@ import {
} from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { GroupService } from './group.service';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
@@ -20,6 +20,7 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../../integrations/audit/audit.service';
import { dbOrTx } from '@docmost/db/utils';
@Injectable()
export class GroupUserService {
@@ -54,17 +55,23 @@ export class GroupUserService {
userIds: string[],
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await this.groupService.findAndValidateGroup(groupId, workspaceId);
const db = dbOrTx(this.db, trx);
await this.groupService.findAndValidateGroup(groupId, workspaceId, trx);
if (userIds.length === 0) return;
// make sure we have valid workspace users
const validUsers = await this.db
const validUsers = await db
.selectFrom('users')
.select(['id', 'name'])
.where('users.id', 'in', userIds)
.where('users.workspaceId', '=', workspaceId)
.execute();
if (validUsers.length === 0) return;
// prepare users to add to group
const groupUsersToInsert = [];
for (const user of validUsers) {
@@ -75,7 +82,7 @@ export class GroupUserService {
}
// batch insert new group users
await this.db
await db
.insertInto('groupUsers')
.values(groupUsersToInsert)
.onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing())
@@ -216,8 +216,11 @@ export class GroupService {
async findAndValidateGroup(
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<Group> {
const group = await this.groupRepo.findById(groupId, workspaceId);
const group = await this.groupRepo.findById(groupId, workspaceId, {
trx,
});
if (!group) {
throw new NotFoundException('Group not found');
}
@@ -13,10 +13,6 @@ import { CreateUserDto } from '../../auth/dto/create-user.dto';
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['password'] as const),
) {
@IsOptional()
@IsString()
avatarUrl: string;
@IsOptional()
@IsBoolean()
fullPageWidth: boolean;
@@ -110,10 +110,6 @@ export class UserService {
user.email = updateUserDto.email;
}
if (updateUserDto.avatarUrl) {
user.avatarUrl = updateUserDto.avatarUrl;
}
if (updateUserDto.locale) {
user.locale = updateUserDto.locale;
}
@@ -5,15 +5,10 @@ import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsString()
logo: string;
@IsOptional()
@IsArray()
emailDomains: string[];
@@ -46,6 +41,10 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsBoolean()
mcpEnabled: boolean;
@IsOptional()
@IsBoolean()
isScimEnabled: boolean;
@IsOptional()
@IsBoolean()
aiChat: boolean;
@@ -331,7 +331,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined'
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -351,6 +352,14 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.isScimEnabled !== 'undefined') {
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SCIM, ws.plan)) {
throw new ForbiddenException(
'This feature requires a valid license',
);
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -535,6 +544,7 @@ export class WorkspaceService {
'enforceSso',
'enforceMfa',
'emailDomains',
'isScimEnabled',
],
updateWorkspaceDto,
workspaceBefore,
@@ -0,0 +1,32 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('file_tasks')
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('set null').ifNotExists(),
)
.execute();
await db.schema
.alterTable('file_tasks')
.addColumn('metadata', 'jsonb', (col) => col.ifNotExists())
.execute();
await db.schema
.createIndex('idx_file_tasks_page_export')
.ifNotExists()
.on('file_tasks')
.columns(['page_id', 'workspace_id'])
.where(sql.ref('type'), '=', 'export')
.where(sql.ref('deleted_at'), 'is', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropIndex('idx_file_tasks_page_export').execute();
await db.schema.alterTable('file_tasks').dropColumn('page_id').execute();
await db.schema.alterTable('file_tasks').dropColumn('metadata').execute();
}
@@ -0,0 +1,110 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('scim_tokens')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('name', 'varchar', (col) => col.notNull())
.addColumn('token_hash', 'varchar', (col) => col.notNull())
.addColumn('token_last_four', 'varchar(4)', (col) => col.notNull())
.addColumn('last_used_at', 'timestamptz')
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('creator_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('deleted_at', 'timestamptz')
.execute();
await db.schema
.createIndex('idx_scim_tokens_token_hash')
.ifNotExists()
.on('scim_tokens')
.column('token_hash')
.execute();
await db.schema
.createIndex('idx_scim_tokens_workspace_id')
.ifNotExists()
.on('scim_tokens')
.column('workspace_id')
.execute();
await db.schema
.alterTable('users')
.addColumn('scim_external_id', 'text')
.execute();
await db.schema
.createIndex('idx_users_workspace_scim_external_id')
.ifNotExists()
.on('users')
.columns(['workspace_id', 'scim_external_id'])
.where('scim_external_id', 'is not', null)
.unique()
.execute();
await db.schema
.alterTable('groups')
.addColumn('scim_external_id', 'text')
.execute();
await db.schema
.createIndex('idx_groups_workspace_scim_external_id')
.ifNotExists()
.on('groups')
.columns(['workspace_id', 'scim_external_id'])
.where('scim_external_id', 'is not', null)
.unique()
.execute();
await db.schema
.alterTable('groups')
.addColumn('is_external', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.execute();
// Backfill: mark all non-default groups as external in workspaces with SSO group sync enabled
await sql`
UPDATE groups SET is_external = true
WHERE is_default = false
AND workspace_id IN (
SELECT workspace_id FROM auth_providers WHERE group_sync = true
)
`.execute(db);
await db.schema
.alterTable('workspaces')
.addColumn('is_scim_enabled', 'boolean', (col) =>
col.notNull().defaultTo(false),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('scim_tokens').execute();
await db.schema.dropIndex('idx_users_workspace_scim_external_id').execute();
await db.schema.alterTable('users').dropColumn('scim_external_id').execute();
await db.schema.dropIndex('idx_groups_workspace_scim_external_id').execute();
await db.schema.alterTable('groups').dropColumn('scim_external_id').execute();
await db.schema.alterTable('groups').dropColumn('is_external').execute();
await db.schema
.alterTable('workspaces')
.dropColumn('is_scim_enabled')
.execute();
}
@@ -5,7 +5,7 @@ import { InsertableFavorite, Favorite } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { ExpressionBuilder, SelectQueryBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { dbOrTx } from '@docmost/db/utils';
@@ -66,6 +66,7 @@ export class FavoriteRepo {
userId: string,
workspaceId: string,
type: FavoriteType,
spaceId?: string,
): Promise<{ items: string[]; meta: any }> {
const idColumn =
type === FavoriteType.PAGE
@@ -74,12 +75,16 @@ export class FavoriteRepo {
? 'spaceId'
: 'templateId';
const query = this.db
let query = this.db
.selectFrom('favorites')
.select(['favorites.id', `favorites.${idColumn} as entityId`])
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('type', '=', type);
.where('favorites.userId', '=', userId)
.where('favorites.workspaceId', '=', workspaceId)
.where('favorites.type', '=', type);
if (spaceId) {
query = this.applySpaceFilter(query, type, spaceId);
}
const result = await executeWithCursorPagination(query, {
perPage: 250,
@@ -100,6 +105,7 @@ export class FavoriteRepo {
workspaceId: string,
pagination: PaginationOptions,
type?: FavoriteType,
spaceId?: string,
) {
let query = this.db
.selectFrom('favorites')
@@ -111,6 +117,10 @@ export class FavoriteRepo {
query = query.where('favorites.type', '=', type);
}
if (spaceId) {
query = this.applySpaceFilter(query, type, spaceId);
}
if (type === FavoriteType.PAGE || !type) {
query = query.select((eb) => this.withPage(eb));
}
@@ -184,6 +194,39 @@ export class FavoriteRepo {
.execute();
}
private applySpaceFilter<Q extends SelectQueryBuilder<any, any, any>>(
query: Q,
type: FavoriteType | undefined,
spaceId: string,
): Q {
if (type === FavoriteType.PAGE) {
return query.where((eb: any) =>
eb.exists(
eb
.selectFrom('pages')
.select(sql`1`.as('one'))
.whereRef('pages.id', '=', 'favorites.pageId')
.where('pages.spaceId', '=', spaceId),
),
) as Q;
}
if (type === FavoriteType.SPACE) {
return query.where('favorites.spaceId' as any, '=', spaceId) as Q;
}
if (type === FavoriteType.TEMPLATE) {
return query.where((eb: any) =>
eb.exists(
eb
.selectFrom('templates')
.select(sql`1`.as('one'))
.whereRef('templates.id', '=', 'favorites.templateId')
.where('templates.spaceId', '=', spaceId),
),
) as Q;
}
return query;
}
private withPage(eb: ExpressionBuilder<DB, 'favorites'>) {
return jsonObjectFrom(
eb
@@ -9,7 +9,7 @@ import {
} from '@docmost/db/types/entity.types';
import { ExpressionBuilder, sql } from 'kysely';
import { PaginationOptions } from '../../pagination/pagination-options';
import { DB } from '@docmost/db/types/db';
import { DB, Groups } from '@docmost/db/types/db';
import { DefaultGroup } from '../../../core/group/dto/create-group.dto';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@@ -17,16 +17,34 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
export class GroupRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Groups> = [
'id',
'name',
'description',
'isDefault',
'isExternal',
'creatorId',
'workspaceId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(
groupId: string,
workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
opts?: {
includeMemberCount?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.select(this.baseFields)
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where('id', '=', groupId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -35,13 +53,18 @@ export class GroupRepo {
async findByName(
groupName: string,
workspaceId: string,
opts?: { includeMemberCount?: boolean; trx?: KyselyTransaction },
opts?: {
includeMemberCount?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<Group> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('groups')
.selectAll('groups')
.select(this.baseFields)
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -51,8 +74,11 @@ export class GroupRepo {
updatableGroup: UpdatableGroup,
groupId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
await this.db
const db = dbOrTx(this.db, trx);
await db
.updateTable('groups')
.set({ ...updatableGroup, updatedAt: new Date() })
.where('id', '=', groupId)
@@ -68,7 +94,7 @@ export class GroupRepo {
return db
.insertInto('groups')
.values(insertableGroup)
.returningAll()
.returning(this.baseFields)
.executeTakeFirst();
}
@@ -80,7 +106,7 @@ export class GroupRepo {
return (
db
.selectFrom('groups')
.selectAll()
.select(this.baseFields)
// .select((eb) => this.withMemberCount(eb))
.where('isDefault', '=', true)
.where('workspaceId', '=', workspaceId)
@@ -106,7 +132,7 @@ export class GroupRepo {
async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) {
let baseQuery = this.db
.selectFrom('groups')
.selectAll('groups')
.select(this.baseFields)
.select((eb) => this.withMemberCount(eb))
.where('workspaceId', '=', workspaceId);
@@ -44,6 +44,7 @@ export class UserRepo {
opts?: {
includePassword?: boolean;
includeUserMfa?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
@@ -53,6 +54,7 @@ export class UserRepo {
.select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where('id', '=', userId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -64,6 +66,7 @@ export class UserRepo {
opts?: {
includePassword?: boolean;
includeUserMfa?: boolean;
includeScimExternalId?: boolean;
trx?: KyselyTransaction;
},
): Promise<User> {
@@ -73,6 +76,7 @@ export class UserRepo {
.select(this.baseFields)
.$if(opts?.includePassword, (qb) => qb.select('password'))
.$if(opts?.includeUserMfa, (qb) => qb.select(this.withUserMfa))
.$if(opts?.includeScimExternalId, (qb) => qb.select('scimExternalId'))
.where(sql`LOWER(email)`, '=', sql`LOWER(${email})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@@ -34,6 +34,7 @@ export class WorkspaceRepo {
'plan',
'enforceMfa',
'trashRetentionDays',
'isScimEnabled',
];
constructor(@InjectKysely() private readonly db: KyselyDB) {}
+21
View File
@@ -196,6 +196,8 @@ export interface FileTasks {
filePath: string;
fileSize: Int8 | null;
id: Generated<string>;
metadata: Json | null;
pageId: string | null;
source: string | null;
spaceId: string | null;
status: string | null;
@@ -211,7 +213,9 @@ export interface Groups {
description: string | null;
id: Generated<string>;
isDefault: boolean;
isExternal: Generated<boolean>;
name: string;
scimExternalId: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
@@ -336,6 +340,7 @@ export interface Users {
name: string | null;
password: string | null;
role: string | null;
scimExternalId: string | null;
settings: Json | null;
timezone: string | null;
updatedAt: Generated<Timestamp>;
@@ -379,6 +384,7 @@ export interface Workspaces {
enforceMfa: Generated<boolean | null>;
enforceSso: Generated<boolean>;
hostname: string | null;
isScimEnabled: Generated<boolean>;
id: Generated<string>;
licenseKey: string | null;
logo: string | null;
@@ -408,6 +414,20 @@ export interface Notifications {
createdAt: Generated<Timestamp>;
}
export interface ScimTokens {
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
id: Generated<string>;
isEnabled: Generated<boolean>;
lastUsedAt: Timestamp | null;
name: string;
tokenHash: string;
tokenLastFour: string;
creatorId: string | null;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface Watchers {
id: Generated<string>;
userId: string;
@@ -556,6 +576,7 @@ export interface DB {
pageVerifications: PageVerifications;
pageVerifiers: PageVerifiers;
pages: Pages;
scimTokens: ScimTokens;
shares: Shares;
spaceMembers: SpaceMembers;
spaces: Spaces;
@@ -29,6 +29,7 @@ import {
UserMfa as _UserMFA,
UserSessions,
ApiKeys,
ScimTokens,
Watchers,
Audit as _Audit,
Templates,
@@ -159,6 +160,11 @@ export type ApiKey = Selectable<ApiKeys>;
export type InsertableApiKey = Insertable<ApiKeys>;
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
// Scim Tokens
export type ScimToken = Selectable<ScimTokens>;
export type InsertableScimToken = Insertable<ScimTokens>;
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
// Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
@@ -75,6 +75,10 @@ export class EnvironmentService {
return new Date(Date.now() + msUntilExpiry);
}
getGotenbergUrl(): string | undefined {
return this.configService.get<string>('GOTENBERG_URL');
}
getStorageDriver(): string {
return this.configService.get<string>('STORAGE_DRIVER', 'local');
}
@@ -300,4 +304,11 @@ export class EnvironmentService {
getClickHouseUrl(): string {
return this.configService.get<string>('CLICKHOUSE_URL');
}
getSamlDisableRequestedAuthnContext(): boolean {
const disabled = this.configService
.get<string>('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false')
.toLowerCase();
return disabled === 'true';
}
}
@@ -23,9 +23,12 @@ import {
SpaceCaslSubject,
} from '../../core/casl/interfaces/space-ability.type';
import { FastifyReply } from 'fastify';
import { sanitize } from 'sanitize-filename-ts';
import { getExportExtension } from './utils';
import { getMimeType, getPageTitle } from '../../common/helpers';
import {
getMimeType,
getPageTitle,
sanitizeFileName,
} from '../../common/helpers';
import * as path from 'path';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
@@ -85,7 +88,9 @@ export class ExportController {
if (result.type === 'file') {
const ext = getExportExtension(dto.format);
const fileName = sanitize(page.title || 'untitled') + ext;
const fileName =
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
ext;
const contentType = getMimeType(path.extname(fileName));
res.headers({
@@ -96,7 +101,9 @@ export class ExportController {
res.send(result.content);
} else {
const fileName = sanitize(page.title || 'untitled') + '.zip';
const fileName =
sanitizeFileName(page.title || 'untitled', { preserveSpaces: true }) +
'.zip';
res.headers({
'Content-Type': 'application/zip',
@@ -144,7 +151,9 @@ export class ExportController {
'Content-Type': 'application/zip',
'Content-Disposition':
'attachment; filename="' +
encodeURIComponent(sanitize(exportFile.fileName)) +
encodeURIComponent(
sanitizeFileName(exportFile.fileName, { preserveSpaces: true }),
) +
'"',
});
@@ -39,6 +39,8 @@ import {
} from '../../common/helpers/prosemirror/utils';
import { htmlToMarkdown } from '@docmost/editor-ext';
type AllowedAttachment = { id: string; fileName: string; filePath: string };
@Injectable()
export class ExportService {
private readonly logger = new Logger(ExportService.name);
@@ -272,6 +274,12 @@ export class ExportService {
computeLocalPath(tree, format, null, '', slugIdToPath);
// Batch resolve attachments once for the whole export so we only run the
// owning-page view check a single time, regardless of page count.
const allowedAttachments = includeAttachments
? await this.resolveAccessibleAttachments(tree, userId, ignorePermissions)
: new Map<string, AllowedAttachment>();
const stack: { folder: JSZip; parentPageId: string | null }[] = [
{ folder: zip, parentPageId: null },
];
@@ -301,7 +309,7 @@ export class ExportService {
);
if (includeAttachments) {
await this.zipAttachments(updatedJsonContent, page.spaceId, folder);
await this.zipAttachments(updatedJsonContent, folder, allowedAttachments);
updatedJsonContent =
updateAttachmentUrlsToLocalPaths(updatedJsonContent);
}
@@ -347,31 +355,80 @@ export class ExportService {
zip.file('docmost-metadata.json', JSON.stringify(metadata, null, 2));
}
async zipAttachments(prosemirrorJson: any, spaceId: string, zip: JSZip) {
async zipAttachments(
prosemirrorJson: any,
zip: JSZip,
allowed: Map<string, AllowedAttachment>,
) {
const attachmentIds = getAttachmentIds(prosemirrorJson);
if (attachmentIds.length > 0) {
const attachments = await this.db
.selectFrom('attachments')
.select(['id', 'fileName', 'filePath'])
.where('id', 'in', attachmentIds)
.where('spaceId', '=', spaceId)
.execute();
await Promise.all(
attachmentIds.map(async (id) => {
const attachment = allowed.get(id);
if (!attachment) return;
try {
const fileBuffer = await this.storageService.read(
attachment.filePath,
);
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
zip.file(filePath, fileBuffer);
} catch (err) {
this.logger.debug(`Attachment export error ${attachment.id}`, err);
}
}),
);
}
await Promise.all(
attachments.map(async (attachment) => {
try {
const fileBuffer = await this.storageService.read(
attachment.filePath,
);
const filePath = `/files/${attachment.id}/${attachment.fileName}`;
zip.file(filePath, fileBuffer);
} catch (err) {
this.logger.debug(`Attachment export error ${attachment.id}`, err);
}
}),
private async resolveAccessibleAttachments(
tree: PageExportTree,
userId: string | undefined,
ignorePermissions: boolean,
): Promise<Map<string, AllowedAttachment>> {
const allAttachmentIds = new Set<string>();
let spaceId: string | undefined;
for (const siblings of Object.values(tree)) {
for (const page of siblings) {
if (!spaceId) spaceId = page.spaceId;
for (const id of getAttachmentIds(getProsemirrorContent(page.content))) {
allAttachmentIds.add(id);
}
}
}
if (allAttachmentIds.size === 0 || !spaceId) {
return new Map();
}
const attachments = await this.db
.selectFrom('attachments')
.select(['id', 'fileName', 'filePath', 'pageId'])
.where('id', 'in', [...allAttachmentIds])
.where('spaceId', '=', spaceId)
.execute();
let visible = attachments;
if (!ignorePermissions && userId) {
const ownerPageIds = [
...new Set(
attachments
.map((a) => a.pageId)
.filter((id): id is string => !!id),
),
];
const accessible = ownerPageIds.length
? await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds: ownerPageIds,
userId,
spaceId,
})
: [];
const accessibleSet = new Set(accessible);
visible = attachments.filter(
(a) => a.pageId && accessibleSet.has(a.pageId),
);
}
return new Map(visible.map((a) => [a.id, a]));
}
async turnPageMentionsToLinks(
@@ -51,9 +51,9 @@ export class ImportController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const validFileExtensions = ['.md', '.html', '.docx'];
const validFileExtensions = ['.md', '.html', '.docx', '.pdf'];
const maxFileSize = bytes('20mb');
const maxFileSize = bytes('30mb');
let file = null;
try {
@@ -102,6 +102,7 @@ export class ImportController {
'.md': 'markdown',
'.html': 'html',
'.docx': 'docx',
'.pdf': 'pdf',
};
if (createdPage) {
@@ -5,6 +5,9 @@ import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { FileImportTaskService } from '../services/file-import-task.service';
import { FileTaskStatus } from '../utils/file.utils';
import { StorageService } from '../../storage/storage.service';
import { ModuleRef } from '@nestjs/core';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
@Processor(QueueName.FILE_TASK_QUEUE)
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@@ -13,6 +16,8 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
constructor(
private readonly fileTaskService: FileImportTaskService,
private readonly storageService: StorageService,
private readonly moduleRef: ModuleRef,
@InjectKysely() private readonly db: KyselyDB,
) {
super();
}
@@ -23,8 +28,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
case QueueJob.IMPORT_TASK:
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
break;
case QueueJob.EXPORT_TASK:
// TODO: export task
case QueueJob.PDF_EXPORT_TASK:
await this.processExportTask(job.data.fileTaskId);
break;
case QueueJob.PDF_EXPORT_CLEANUP:
await this.processExportCleanup();
break;
}
} catch (err) {
@@ -33,6 +41,24 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
}
}
private getPdfExportService() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const PdfExportModule = require('./../../../ee/pdf-export/pdf-export.service');
return this.moduleRef.get(PdfExportModule.PdfExportService, {
strict: false,
});
}
private async processExportTask(fileTaskId: string): Promise<void> {
const pdfExportService = this.getPdfExportService();
await pdfExportService.generateAndStorePdf(fileTaskId);
}
private async processExportCleanup(): Promise<void> {
const pdfExportService = this.getPdfExportService();
await pdfExportService.cleanupExpiredExports();
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
@@ -41,32 +67,39 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
@OnWorkerEvent('failed')
async onFailed(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Import Task ID: ${job.data.fileTaskId}. Reason: ${job.failedReason}`,
`Error processing ${job.name} job. File Task ID: ${job.data?.fileTaskId}. Reason: ${job.failedReason}`,
);
await this.handleFailedJob(job);
if (job.name === QueueJob.IMPORT_TASK) {
await this.handleFailedImportJob(job);
} else if (job.name === QueueJob.PDF_EXPORT_TASK) {
await this.handleFailedExportJob(job);
}
}
@OnWorkerEvent('completed')
async onCompleted(job: Job) {
this.logger.log(
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
`Completed ${job.name} job for File task ID ${job.data?.fileTaskId}`,
);
try {
const fileTask = await this.fileTaskService.getFileTask(
job.data.fileTaskId,
);
if (fileTask) {
await this.storageService.delete(fileTask.filePath);
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
if (job.name === QueueJob.IMPORT_TASK) {
try {
const fileTask = await this.fileTaskService.getFileTask(
job.data.fileTaskId,
);
if (fileTask) {
await this.storageService.delete(fileTask.filePath);
this.logger.debug(`Deleted imported zip file: ${fileTask.filePath}`);
}
} catch (err) {
this.logger.error(`Failed to delete imported zip file:`, err);
}
} catch (err) {
this.logger.error(`Failed to delete imported zip file:`, err);
}
// Export tasks: do NOT delete the file on completion (kept for 24h cache)
}
private async handleFailedJob(job: Job) {
private async handleFailedImportJob(job: Job) {
try {
const fileTaskId = job.data.fileTaskId;
const reason = job.failedReason || 'Unknown error';
@@ -86,6 +119,25 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
}
}
private async handleFailedExportJob(job: Job) {
try {
const fileTaskId = job.data.fileTaskId;
const reason = job.failedReason || 'Unknown error';
await this.db
.updateTable('fileTasks')
.set({
status: FileTaskStatus.Failed,
errorMessage: reason,
updatedAt: new Date(),
})
.where('id', '=', fileTaskId)
.execute();
} catch (err) {
this.logger.error(err);
}
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
@@ -1,7 +1,6 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { MultipartFile } from '@fastify/multipart';
import { sanitize } from 'sanitize-filename-ts';
import * as path from 'path';
import {
htmlToJson,
@@ -30,6 +29,8 @@ import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../queue/constants';
import { ModuleRef } from '@nestjs/core';
import { load } from 'cheerio';
import { normalizeImportHtml } from '../utils/import-formatter';
@Injectable()
export class ImportService {
@@ -53,8 +54,8 @@ export class ImportService {
const file = await filePromise;
const fileBuffer = await file.toBuffer();
const fileExtension = path.extname(file.filename).toLowerCase();
const fileName = sanitize(
path.basename(file.filename, fileExtension).slice(0, 255),
const fileName = sanitizeFileName(
path.basename(file.filename, fileExtension),
);
const fileContent = fileBuffer.toString();
@@ -62,7 +63,10 @@ export class ImportService {
let createdPage = null;
// For DOCX, we need the page ID upfront so images can reference it
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
const pageId =
fileExtension === '.docx' || fileExtension === '.pdf'
? uuid7()
: undefined;
try {
if (fileExtension.endsWith('.md')) {
@@ -77,6 +81,14 @@ export class ImportService {
pageId,
userId,
);
} else if (fileExtension.endsWith('.pdf')) {
prosemirrorState = await this.processPdf(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
}
} catch (err) {
const message = 'Error processing file content';
@@ -137,7 +149,9 @@ export class ImportService {
async processHTML(htmlInput: string): Promise<any> {
try {
return htmlToJson(htmlInput);
const $ = load(htmlInput);
normalizeImportHtml($, $.root());
return htmlToJson($.html() || '');
} catch (err) {
throw err;
}
@@ -153,7 +167,7 @@ export class ImportService {
let DocxImportModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
DocxImportModule = require('./../../../ee/document-import/docx-import.service');
} catch (err) {
this.logger.error(
'DOCX import requested but EE module not bundled in this build',
@@ -179,6 +193,42 @@ export class ImportService {
return this.processHTML(html);
}
async processPdf(
fileBuffer: Buffer,
workspaceId: string,
spaceId: string,
pageId: string,
userId: string,
): Promise<any> {
let PdfImportModule: any;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
PdfImportModule = require('./../../../ee/document-import/pdf-import.service');
} catch (err) {
this.logger.error(
'PDF import requested but EE module not bundled in this build',
);
throw new BadRequestException(
'This feature requires a valid enterprise license.',
);
}
const pdfImportService = this.moduleRef.get(
PdfImportModule.PdfImportService,
{ strict: false },
);
const html = await pdfImportService.convertPdfToHtml(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
return this.processHTML(html);
}
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) {
// this.logger.debug(`Converting prosemirror json state to ydoc`);
@@ -5,6 +5,7 @@ import { v7 } from 'uuid';
import { InsertableBacklink } from '@docmost/db/types/entity.types';
import { Cheerio, CheerioAPI, load } from 'cheerio';
import slugify from '@sindresorhus/slugify';
import { normalizeTableColumnWidths } from './table-utils';
// Check if text contains Unicode characters (for emojis/icons)
function isUnicodeCharacter(text: string): boolean {
@@ -51,9 +52,7 @@ export async function formatImportHtml(opts: {
}
}
notionFormatter($, $root);
xwikiFormatter($, $root);
defaultHtmlFormatter($, $root);
normalizeImportHtml($, $root);
const backlinks = await rewriteInternalLinksToMentionHtml(
$,
@@ -73,6 +72,23 @@ export async function formatImportHtml(opts: {
};
}
/**
* Contextless HTML cleanup shared by every import path.
* - notionFormatter: no-op on non-Notion HTML (class-selector-based).
* - xwikiFormatter: no-op on non-XWiki HTML (looks for #xwikicontent).
* - defaultHtmlFormatter: table column widths + provider auto-embeds.
*
* Does NOT run rewriteInternalLinksToMentionHtml that requires zip context.
*/
export function normalizeImportHtml(
$: CheerioAPI,
$root: Cheerio<any>,
): void {
notionFormatter($, $root);
xwikiFormatter($, $root);
defaultHtmlFormatter($, $root);
}
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
const $content = $root.find('#xwikicontent');
if ($content.length) {
@@ -82,6 +98,8 @@ export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
}
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
normalizeTableColumnWidths($, $root);
$root.find('a[href]').each((_, el) => {
const $el = $(el);
const url = $el.attr('href')!;
@@ -0,0 +1,107 @@
import { CheerioAPI, Cheerio } from 'cheerio';
const DEFAULT_IMPORT_COL_WIDTH_PX = 150;
/**
* Extracts a pixel-integer width from either the `width` attribute or
* `style="width: Npx"` on a <col>/<td>/<th>. Returns null when absent,
* non-numeric, or a non-px unit (em, %).
*/
function parsePixelWidth(el: Cheerio<any>): number | null {
const attr = el.attr('width');
if (attr) {
const n = parseInt(attr, 10);
if (Number.isFinite(n) && n > 0) return n;
}
const style = el.attr('style') || '';
const m = style.match(/(?:^|;)\s*width\s*:\s*([\d.]+)\s*px/i);
if (m) {
const n = parseInt(m[1], 10);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
/**
* Derives per-column widths for a table, in visual column order.
* Priority: <colgroup><col> first-row cells' own width style.
* Returns an array of length = number of columns, with null entries
* for columns whose width couldn't be determined.
*/
function deriveColumnWidths(
$: CheerioAPI,
table: Cheerio<any>,
): (number | null)[] | null {
const cols = table.find('> colgroup > col');
if (cols.length > 0) {
const widths: (number | null)[] = [];
cols.each(function () {
widths.push(parsePixelWidth($(this)));
});
if (widths.some((w) => w !== null)) return widths;
}
// Fallback: first row's cells.
const firstRow = table.find('> tbody > tr, > thead > tr, > tr').first();
if (!firstRow.length) return null;
const widths: (number | null)[] = [];
firstRow.children('td, th').each(function () {
const cell = $(this);
const colspan = parseInt(cell.attr('colspan') || '1', 10) || 1;
const w = parsePixelWidth(cell);
for (let i = 0; i < colspan; i++) {
widths.push(w !== null ? Math.round(w / colspan) : null);
}
});
if (widths.every((w) => w === null)) return null;
return widths;
}
/**
* Apply colwidth attributes to the first row of each table based on
* derived column widths. Accounts for colspan. Idempotent re-running
* on already-normalized markup is a no-op.
*
* This lives upstream of tiptap's generateJSON: tiptap reads
* `colwidth="N[,N...]"` on <td>/<th> to build the runtime <colgroup>.
*/
export function normalizeTableColumnWidths(
$: CheerioAPI,
$root: Cheerio<any>,
): void {
$root.find('table').each(function () {
const table = $(this);
const firstRow = table.find('> tbody > tr, > thead > tr, > tr').first();
if (!firstRow.length) return;
let colWidths = deriveColumnWidths($, table);
if (!colWidths) {
// No widths anywhere (e.g. markdown-sourced tables). Apply a default
// per-column width so the table's intrinsic width can exceed the
// editor container, letting .tableWrapper's overflow-x: auto scroll
// instead of cramming columns into the available width.
let count = 0;
firstRow.children('td, th').each(function () {
count += parseInt($(this).attr('colspan') || '1', 10) || 1;
});
if (count === 0) return;
colWidths = new Array(count).fill(DEFAULT_IMPORT_COL_WIDTH_PX);
}
let col = 0;
firstRow.children('td, th').each(function () {
const cell = $(this);
if (cell.attr('colwidth')) {
col += parseInt(cell.attr('colspan') || '1', 10) || 1;
return;
}
const colspan = parseInt(cell.attr('colspan') || '1', 10) || 1;
const slice = colWidths.slice(col, col + colspan);
col += colspan;
if (slice.length === 0 || slice.every((w) => w === null)) return;
const values = slice.map((w) => (w == null ? 100 : w));
cell.attr('colwidth', values.join(','));
});
});
}
@@ -80,4 +80,7 @@ export enum QueueJob {
AUDIT_LOG = 'audit-log',
AUDIT_CLEANUP = 'audit-cleanup',
PDF_EXPORT_TASK = 'pdf-export-task',
PDF_EXPORT_CLEANUP = 'pdf-export-cleanup',
}
@@ -0,0 +1,67 @@
import { resolve, sep } from 'path';
import { LocalDriver } from './local.driver';
type FullPath = (filePath: string) => string;
describe('LocalDriver._fullPath', () => {
const ROOT = resolve('/data/storage');
const driver = new LocalDriver({ storagePath: ROOT });
const fullPath = ((driver as any)._fullPath as FullPath).bind(driver);
describe('legitimate inputs (behavior preserved)', () => {
it.each([
['workspace-id/avatars/uuid.png', `${ROOT}${sep}workspace-id${sep}avatars${sep}uuid.png`],
['workspace-id/files/uuid/file.pdf', `${ROOT}${sep}workspace-id${sep}files${sep}uuid${sep}file.pdf`],
['a/b/c/d/e.bin', `${ROOT}${sep}a${sep}b${sep}c${sep}d${sep}e.bin`],
['', ROOT],
['.', ROOT],
['./x/y.png', `${ROOT}${sep}x${sep}y.png`],
['a//b', `${ROOT}${sep}a${sep}b`],
['a/b/../c', `${ROOT}${sep}a${sep}c`],
])('resolves %j to %j', (input, expected) => {
expect(fullPath(input)).toBe(expected);
});
});
describe('traversal rejected', () => {
it.each([
'../etc/passwd',
'../../../etc/passwd',
'workspace/../../../etc/passwd',
'..',
'../..',
'a/../../..',
])('throws for %j', (input) => {
expect(() => fullPath(input)).toThrow('Invalid file path');
});
});
describe('absolute path rejected', () => {
it.each([
'/etc/passwd',
'/root/.ssh/id_rsa',
sep + 'absolute',
])('throws for %j', (input) => {
expect(() => fullPath(input)).toThrow('Invalid file path');
});
});
describe('prefix-confusion rejected', () => {
it('rejects a sibling directory whose name starts with the storage root', () => {
const siblingDriver = new LocalDriver({ storagePath: '/data/storage' });
const siblingFullPath = ((siblingDriver as any)._fullPath as FullPath).bind(siblingDriver);
// Attempt to reach /data/storage-evil/secret by traversal:
// resolve('/data/storage', '../storage-evil/secret') === '/data/storage-evil/secret'
// Without the `+ sep` guard, a startsWith check would match.
expect(() => siblingFullPath('../storage-evil/secret')).toThrow('Invalid file path');
});
});
describe('storage root itself', () => {
it('accepts the root when input resolves to it', () => {
expect(fullPath('')).toBe(ROOT);
expect(fullPath('.')).toBe(ROOT);
expect(fullPath('a/..')).toBe(ROOT);
});
});
});
@@ -3,7 +3,7 @@ import {
LocalStorageConfig,
StorageOption,
} from '../interfaces';
import { join, dirname } from 'path';
import { dirname, resolve, sep } from 'path';
import * as fs from 'fs-extra';
import { Readable } from 'stream';
import { createReadStream, createWriteStream } from 'node:fs';
@@ -17,7 +17,12 @@ export class LocalDriver implements StorageDriver {
}
private _fullPath(filePath: string): string {
return join(this.config.storagePath, filePath);
const storageRoot = resolve(this.config.storagePath);
const fullPath = resolve(storageRoot, filePath);
if (fullPath !== storageRoot && !fullPath.startsWith(storageRoot + sep)) {
throw new Error('Invalid file path');
}
return fullPath;
}
async upload(filePath: string, file: Buffer | Readable): Promise<void> {
+16
View File
@@ -50,6 +50,22 @@ async function bootstrap() {
await app.register(fastifyMultipart);
await app.register(fastifyCookie);
app
.getHttpAdapter()
.getInstance()
.addContentTypeParser(
'application/scim+json',
{ parseAs: 'string' },
(_, body, done) => {
try {
const json = JSON.parse(body.toString());
done(null, json);
} catch (err: any) {
done(err);
}
},
);
app
.getHttpAdapter()
.getInstance()
+12 -9
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.71.1",
"version": "0.80.1",
"private": true,
"scripts": {
"build": "nx run-many -t build",
@@ -62,7 +62,7 @@
"cross-env": "^10.1.0",
"date-fns": "^4.1.0",
"diff": "8.0.3",
"dompurify": "^3.3.3",
"dompurify": "3.4.1",
"fractional-indexing-jittered": "^1.0.0",
"highlight.js": "^11.11.1",
"image-dimensions": "^2.5.0",
@@ -72,7 +72,7 @@
"ms": "3.0.0-canary.1",
"qrcode": "^1.5.4",
"rfc6902": "5.2.0",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7",
"yjs": "^13.6.30"
@@ -95,16 +95,17 @@
"packageManager": "pnpm@10.4.0",
"pnpm": {
"patchedDependencies": {
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch"
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch",
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch"
},
"overrides": {
"prosemirror-changeset": "2.4.0",
"y-prosemirror": "1.3.7",
"glob": "13.0.6",
"ws": "8.20.0",
"dompurify": "3.3.3",
"dompurify": "3.4.1",
"tmp": "0.2.5",
"hono": "4.12.12",
"hono": "4.12.14",
"mermaid": "11.13.0",
"nanoid@^3": "3.3.8",
"socket.io-parser": "4.2.6",
@@ -123,15 +124,17 @@
"flatted": "3.4.2",
"picomatch@<2.3.2": "2.3.2",
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"fastify": "5.8.3",
"fastify": "5.8.5",
"yaml@>=1.0.0 <1.10.3": "1.10.3",
"yaml@>=2.0.0 <2.8.3": "2.8.3",
"path-to-regexp@^8": "8.4.0",
"brace-expansion@^5": "5.0.5",
"@xmldom/xmldom": "0.8.12",
"@xmldom/xmldom": "0.8.13",
"handlebars": "4.7.9",
"axios": "1.15.0",
"langsmith": "0.5.18"
"langsmith": "0.5.19",
"follow-redirects": "1.16.0",
"protobufjs": "7.5.5"
},
"neverBuiltDependencies": []
}

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