Compare commits

..

95 Commits

Author SHA1 Message Date
60a8ed6826 sync 2025-10-25 02:08:29 +01:00
f5684b792e fix duplicated page parenting (#1692) 2025-10-23 15:00:11 +01:00
042836cb6d sync 2025-10-07 21:09:55 +01:00
4f1f0ba513 fix 2025-10-07 21:06:59 +01:00
3164b6981c feat: api keys management (EE) (#1665)
* feat: api keys (EE)

* improvements

* fix table

* fix route

* remove token suffix

* api settings

* Fix

* fix

* fix

* fix
2025-10-07 21:05:13 +01:00
16c1e864af fix comment space 2025-10-07 18:44:37 +01:00
c9b1cad982 sync 2025-10-07 18:39:30 +01:00
bf8cf6254f feat: Typesense search driver (EE) (#1664)
* feat: typesense driver (EE) - WIP

* feat: typesense driver (EE) - WIP

* feat: typesense

* sync

* fix
2025-10-07 17:34:32 +01:00
3135030376 fix editor converter (#1647) 2025-09-30 16:07:19 +01:00
3fae41a5ca fix: editor performance improvements (#1648)
* Switch to useEditorState
* change shouldRerenderOnTransaction to false
2025-09-30 14:04:01 +01:00
b50e25600a sync 2025-09-28 16:44:33 +01:00
1f3b0c7276 cloud fix 2025-09-24 21:25:39 +01:00
3c4cab0d2a v0.23.2 2025-09-18 18:00:28 +01:00
4de25a8b94 invalidate queries on space deletion 2025-09-18 15:52:53 +01:00
cf5bbb10df fix import html processing 2025-09-18 15:34:13 +01:00
ac17521717 sync 2025-09-18 13:24:16 +01:00
9ac180f719 fix: enhance page import (#1570)
* change import process

* fix processor

* fix page name in notion import

* preserve confluence table bg color

* sync
2025-09-17 23:50:27 +01:00
46669fea56 (cloud) disable page sharing in trial mode 2025-09-17 23:36:13 +01:00
fe6ecdf1f1 fix: update combobox props in SpaceSelect component (#1564)
Added 'keepMounted: false' and 'dropdownPadding: 0' to comboboxProps for improved dropdown behavior and appearance in the SpaceSelect sidebar component.
2025-09-17 13:36:12 +01:00
04ae1d7270 Allow lastColumnResizable in table 2025-09-15 22:34:29 +01:00
1280f96f37 feat: implement space and workspace icons (#1558)
* feat: implement space and workspace icons
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Add Sharp package for server-side image resizing and optimization
- Create reusable AvatarUploader component supporting avatars, space icons, and workspace icons
- Support removing icons

* add workspace logo support
- add upload loader
- add white background to transparent image
- other fixes and enhancements

* dark mode

* fixes

* cleanup
2025-09-15 21:11:37 +01:00
61d1cf88a7 fix: reset file inputs after import 2025-09-15 12:52:31 +01:00
f413720e15 - sync
- reinstantiate S3 client to fix file upload errors during import
- delete import zip file after use
2025-09-14 03:00:23 +01:00
8e16ad952a v0.23.1 2025-09-13 03:15:53 +01:00
7ada3cb1f9 fix: page import task (#1551)
* fix import

* - fix notion importer
- support notion page icon import
- fix horizontal rule css
- rename service file

* sync

* 3 mins delay
2025-09-13 03:14:59 +01:00
47c54174b3 sync 2025-09-11 00:50:15 +01:00
dc0650289d sync 2025-09-04 15:07:01 -07:00
091e790b83 fix attachment search in cloud 2025-09-04 14:22:40 -07:00
ae24ea29ba v0.23.0 2025-09-04 13:42:59 -07:00
9df6061e1a lock file 2025-09-04 13:42:33 -07:00
31053e2b20 update mermaid 2025-09-04 13:41:55 -07:00
eb8e8507ea use debug 2025-09-04 13:27:15 -07:00
c99bfb8ef1 make print better 2025-09-04 13:22:43 -07:00
26ea04e2a3 sync 2025-09-04 12:25:53 -07:00
6cc58c57f5 sync 2025-09-04 12:16:30 -07:00
7d2ff346fa UI fixes 2025-09-04 11:35:04 -07:00
b08d37fbf0 fix 2025-09-04 10:57:17 -07:00
d43ee77617 remove debug log 2025-09-04 09:40:17 -07:00
5d91eb4f5f feat: queue imported attachments for indexing 2025-09-04 09:38:30 -07:00
3e9f6b11cc Remove version from docker-compose.yml [deprecated] (#1011) 2025-09-04 03:55:32 +01:00
db55de9406 feat: progressive web app (#614)
* feat: progressive web app

* replace icons

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-09-04 01:33:52 +01:00
1919eba340 New Crowdin updates (#1522)
* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2025-09-03 13:17:08 -07:00
7951b2e0c6 New Crowdin updates (#1509)
* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (French)

* New translations translation.json (Spanish)

* New translations translation.json (German)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Dutch)

* New translations translation.json (Russian)

* New translations translation.json (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2025-09-03 18:28:30 +01:00
73b78f625d more translations 2025-09-03 10:11:19 -07:00
cf7534de3d fix version display 2025-09-03 09:37:29 -07:00
adec36d544 fix: adjust margins
- use default browser highlight background
2025-09-02 21:45:38 -07:00
f9e10805f0 sync 2025-09-02 21:38:14 -07:00
00e499b3e5 Fixing extra page bug on print (#1478) 2025-09-03 05:25:48 +01:00
5ee6e46535 checkbox aligned to text (#1486) 2025-09-03 05:23:28 +01:00
1f797c3d27 fix: confluence drawio import (#1518)
* POC

* WIP - working

* WIP

* WIP

* sync

* fix drawio preview image
2025-09-03 05:19:09 +01:00
f12866cf42 feat(EE): full-text search in attachments (#1502)
* feat(EE): fulltext search in attachments

* feat: global search
- search filters
- attachments search ui
- and more

* fix import

* fix import

* rename migration

* add GIN index

* fix table name

* sanitize
2025-09-02 05:27:01 +01:00
dcbb65d799 feat(EE): LDAP integration (#1515)
* LDAP - WIP

* WIP

* add hasGeneratedPassword

* fix jotai atom

* - don't require password confirmation for MFA is user has auto generated password (LDAP)
- cleanups

* fix

* reorder

* update migration

* update default

* fix type error
2025-09-02 04:59:01 +01:00
5968764508 feat: emoji callout icon (#1323) 2025-08-31 21:16:52 +01:00
242fb6bb57 fix: set mermaid theme based on computed color scheme (#1438) 2025-08-31 20:48:59 +01:00
74cd890bdd feat(EE): implement SSO group sync for SAML and OIDC (#1452)
* feat: implement SSO group synchronization for SAML and OIDC

- Add group_sync column to auth_providers table
- Extract groups from SAML attributes (memberOf, groups, roles)
- Extract groups from OIDC claims (groups, roles)
- Implement case-insensitive group matching with auto-creation
- Sync user groups on each SSO login
- Ensure only one provider can have group sync enabled at a time
- Add group sync toggle to SAML and OIDC configuration forms

* rename column
2025-08-31 20:33:37 +01:00
509622af54 ignore type error 2025-08-31 12:20:40 -07:00
937386e42b fix: hide table handles in readonly mode 2025-08-31 12:08:02 -07:00
60a373f488 fix: readonly editor table responsiveness 2025-08-31 12:04:27 -07:00
73ee6ee8c3 feat: subpages (child pages) list node (#1462)
* feat: subpages list node

* disable user-select

* support subpages node list in public pages
2025-08-31 18:54:52 +01:00
7d1e5bce0d feat: table row/column drag and drop (#1467)
* chore: add dev container

* feat: add drag handle when hovering cell

* feat: add column drag and drop

* feat: add support for row drag and drop

* refactor: extract preview controllers

* fix: hover issue

* refactor: add handle controller

* chore: f

* chore: remove log

* chore: remove dev files

* feat: hide other drop indicators when table dnd working

* feat: add auto scroll and bug fix

* chore: f

* fix: firefox
2025-08-31 18:53:27 +01:00
aa58e272d6 fix: exclude deleted pages (#1494) 2025-08-31 09:11:33 +01:00
08135a2fba sync 2025-08-12 11:09:26 -07:00
d92a94244f sync 2025-08-12 10:21:17 -07:00
5012a68d85 sync 2025-08-06 10:19:35 -07:00
5a3377790e feat: debug mode env variable (#1450) 2025-08-06 18:16:30 +01:00
3b85f4b616 fix: enforce C collation for page position ordering to ensure consistent behavior in Postgres 17+ (#1446)
- Add explicit C collation to position ordering queries to fix incorrect page placement in PostgreSQL 17+
- Ensures consistent ASCII-based ordering regardless of database locale settings
- Fixes issue where new pages were incorrectly placed at random positions instead of bottom
2025-08-04 09:49:29 +01:00
cb2a0398c7 fix: invalidate trashed page from tree state 2025-08-04 00:42:13 -07:00
95b7be61df fix: hide trash from can view permission (#1445) 2025-08-04 08:35:28 +01:00
b0c557272d fix nested taskList in markdown export (#1443) 2025-08-04 08:01:18 +01:00
dddfd48934 feat: add attachments support for single page exports (#1440)
* feat: add attachments support for single page exports
- Add includeAttachments option to page export modal and API
- Fix internal page url in single page exports in cloud

* remove redundant line

* preserve export state
2025-08-04 08:01:11 +01:00
aa6eec754e fix: exclude trashed pages from position generation 2025-08-04 00:00:06 -07:00
97a7701f5d fix local storage copy function (#1442) 2025-08-04 03:20:18 +01:00
b97eb85d05 sync 2025-08-03 03:59:08 -07:00
1615e0f4ad v0.22.2 2025-08-01 16:15:02 -07:00
1cb2535de3 fix trash in search (#1439)
- delete share if page is trashed
2025-08-02 00:14:00 +01:00
83bc273cb0 cleanup 2025-08-01 07:05:25 -07:00
c7beaa3742 v0.22.1 2025-08-01 06:54:28 -07:00
4a228e5a51 fix comment replies 2025-08-01 06:51:56 -07:00
edff375476 sync 2025-08-01 02:54:11 -07:00
95016b2bfc sync 2025-08-01 02:51:55 -07:00
ca83712364 cleanup 2025-08-01 02:26:14 -07:00
39550fe906 fix: duplicate page position bug (#1431) 2025-07-30 18:07:06 +01:00
e74ecb2604 v0.22.0 2025-07-29 15:22:46 -07:00
992fb23160 update lock file 2025-07-29 15:04:38 -07:00
d58a3bba9b update linkify 2025-07-29 14:59:50 -07:00
6ef47fc432 show button only if necessary 2025-07-29 14:59:23 -07:00
9e6765d83c fix 2025-07-29 14:51:55 -07:00
ec0ed5c630 fix import 2025-07-29 14:50:59 -07:00
77b334ea37 reorder migration 2025-07-29 14:49:19 -07:00
5da92a538a feat: add unaccent support for accent-insensitive search (#1402)
- Add PostgreSQL unaccent and pg_trgm extensions
- Create immutable f_unaccent wrapper function for performance
- Update all search queries to use f_unaccent for accent-insensitive matching
- Add 1MB limit to tsvector content to prevent errors on large documents
- Update full-text search trigger to use f_unaccent
- Fix MultiSelect client-side filtering to show server results properly
2025-07-29 22:47:13 +01:00
f90c5a636b cleanup comment 2025-07-29 14:30:45 -07:00
6db93ef0c7 upsell 2025-07-29 14:28:40 -07:00
a3d058042f New Crowdin updates (#1342)
* New translations translation.json (German)

* New translations translation.json (Spanish)

* New translations translation.json (Russian)

* New translations translation.json (Spanish)

* New translations translation.json (Russian)

* New translations translation.json (French)

* 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 (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)

* New translations translation.json (Spanish)

* New translations translation.json (Russian)

* New translations translation.json (French)

* 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 (Ukrainian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2025-07-29 21:53:16 +01:00
4ab9261cf5 sync 2025-07-29 13:41:07 -07:00
ca9558b246 feat(EE): resolve comments (#1420)
* feat: resolve comment (EE)

* Add resolve to comment mark in editor (EE)

* comment ui permissions

* sticky comment state tabs (EE)

* cleanup

* feat: add space_id to comments and allow space admins to delete any comment

- Add space_id column to comments table with data migration from pages
- Add last_edited_by_id, resolved_by_id, and updated_at columns to comments
- Update comment deletion permissions to allow space admins to delete any comment
- Backfill space_id on old comments

* fix foreign keys
2025-07-29 21:36:48 +01:00
273 changed files with 10313 additions and 1397 deletions

View File

@ -43,4 +43,7 @@ POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
DISABLE_TELEMETRY=false
DISABLE_TELEMETRY=false
# Enable debug logging in production (default: false)
DEBUG_MODE=false

View File

@ -2,10 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
<title>Docmost</title>
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Docmost" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!--meta-tags-->
</head>
<body>

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.21.0",
"version": "0.23.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -17,6 +17,7 @@
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
"@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
@ -40,7 +41,7 @@
"katex": "0.16.22",
"lowlight": "^3.3.0",
"mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.6.0",
"mermaid": "^11.11.0",
"mitt": "^3.0.1",
"posthog-js": "^1.255.1",
"react": "^18.3.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "z.B. Bereich für das Produktteam",
"e.g Space for sales team to collaborate": "z.B. Bereich für das Vertriebsteam zur Zusammenarbeit",
"Edit": "Bearbeiten",
"Read": "Lesen",
"Edit group": "Gruppe bearbeiten",
"Email": "E-Mail",
"Enter a strong password": "Geben Sie ein starkes Passwort ein",
@ -104,7 +105,7 @@
"Member": "Mitglied",
"members": "Mitglieder",
"Members": "Mitglieder",
"My preferences": "Meine Vorlieben",
"My preferences": "Meine Voreinstellungen",
"My Profile": "Mein Profil",
"My profile": "Mein Profil",
"Name": "Name",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Kommentar erfolgreich gelöscht",
"Failed to delete comment": "Löschen des Kommentars fehlgeschlagen",
"Comment resolved successfully": "Kommentar erfolgreich gelöst",
"Comment re-opened successfully": "Kommentar erfolgreich wieder geöffnet",
"Comment unresolved successfully": "Kommentar erfolgreich ungelöst",
"Failed to resolve comment": "Lösen des Kommentars fehlgeschlagen",
"Resolve comment": "Kommentar lösen",
"Unresolve comment": "Kommentar nicht lösen",
"Resolve Comment Thread": "Kommentarthread lösen",
"Unresolve Comment Thread": "Kommentarthread nicht lösen",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Sind Sie sicher, dass Sie diesen Kommentarthread lösen möchten? Dies wird als abgeschlossen markiert.",
"Are you sure you want to unresolve this comment thread?": "Sind Sie sicher, dass Sie diesen Kommentarthread nicht lösen möchten?",
"Resolved": "Gelöst",
"No active comments.": "Keine aktiven Kommentare.",
"No resolved comments.": "Keine gelösten Kommentare.",
"Revoke invitation": "Einladung widerrufen",
"Revoke": "Widerrufen",
"Don't": "Nicht",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Jeder mit diesem Link kann dem Arbeitsbereich beitreten.",
"Invite link": "Einladungslink",
"Copy": "Kopieren",
"Copy to space": "In Raum kopieren",
"Copied": "Kopiert",
"Duplicate": "Duplizieren",
"Select a user": "Benutzer auswählen",
"Select a group": "Gruppe auswählen",
"Export all pages and attachments in this space.": "Alle Seiten und Anhänge in diesem Bereich exportieren.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
"New update": "Neues Update",
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
"Default page edit mode": "Standard-Seitenbearbeitungsmodus",
"Choose your preferred page edit mode. Avoid accidental edits.": "Wählen Sie Ihren bevorzugten Seitenbearbeitungsmodus. Vermeiden Sie versehentliche Bearbeitungen.",
"Reading": "Lesen",
"Delete member": "Mitglied löschen",
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.",
@ -386,5 +403,129 @@
"Failed to share page": "Fehler beim Teilen der Seite",
"Copy page": "Seite kopieren",
"Copy page to a different space.": "Seite in einen anderen Bereich kopieren.",
"Page copied successfully": "Seite erfolgreich kopiert"
"Page copied successfully": "Seite erfolgreich kopiert",
"Page duplicated successfully": "Seite erfolgreich dupliziert",
"Find": "Finden",
"Not found": "Nicht gefunden",
"Previous Match (Shift+Enter)": "Vorheriger Treffer (Shift+Enter)",
"Next match (Enter)": "Nächster Treffer (Enter)",
"Match case (Alt+C)": "Groß-/Kleinschreibung beachten (Alt+C)",
"Replace": "Ersetzen",
"Close (Escape)": "Schließen (Escape)",
"Replace (Enter)": "Ersetzen (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Alle ersetzen (Ctrl+Alt+Enter)",
"Replace all": "Alle ersetzen",
"View all spaces": "Alle Räume anzeigen",
"Error": "Fehler",
"Failed to disable MFA": "Deaktivierung der MFA fehlgeschlagen",
"Disable two-factor authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Die Deaktivierung der Zwei-Faktor-Authentifizierung macht Ihr Konto weniger sicher. Sie benötigen nur Ihr Passwort, um sich anzumelden.",
"Please enter your password to disable two-factor authentication:": "Bitte geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren:",
"Two-factor authentication has been enabled": "Zwei-Faktor-Authentifizierung wurde aktiviert",
"Two-factor authentication has been disabled": "Zwei-Faktor-Authentifizierung wurde deaktiviert",
"2-step verification": "2-Schritt-Verifizierung",
"Protect your account with an additional verification layer when signing in.": "Schützen Sie Ihr Konto mit einer zusätzlichen Verifizierungsschicht beim Anmelden.",
"Two-factor authentication is active on your account.": "Die Zwei-Faktor-Authentifizierung ist auf Ihrem Konto aktiv.",
"Add 2FA method": "2FA-Methode hinzufügen",
"Backup codes": "Sicherungscodes",
"Disable": "Deaktivieren",
"Invalid verification code": "Ungültiger Bestätigungscode",
"New backup codes have been generated": "Neue Sicherungscodes wurden generiert",
"Failed to regenerate backup codes": "Fehler beim Generieren neuer Sicherungscodes",
"About backup codes": "Über Sicherungscodes",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Sicherungscodes können verwendet werden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authenticator-App verlieren. Jeder Code kann nur einmal verwendet werden.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Sie können jederzeit neue Sicherungscodes generieren. Dies wird alle vorhandenen Codes ungültig machen.",
"Confirm password": "Passwort bestätigen",
"Generate new backup codes": "Neue Sicherungscodes generieren",
"Save your new backup codes": "Speichern Sie Ihre neuen Sicherungscodes",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Speichern Sie diese Codes an einem sicheren Ort. Ihre alten Sicherungscodes sind nicht mehr gültig.",
"Your new backup codes": "Ihre neuen Sicherungscodes",
"I've saved my backup codes": "Ich habe meine Sicherungscodes gespeichert",
"Failed to setup MFA": "Fehler beim Einrichten der MFA",
"Setup & Verify": "Einrichten & Überprüfen",
"Add to authenticator": "Zum Authenticator hinzufügen",
"1. Scan this QR code with your authenticator app": "1. Scannen Sie diesen QR-Code mit Ihrer Authenticator-App",
"Can't scan the code?": "Code kann nicht gescannt werden?",
"Enter this code manually in your authenticator app:": "Geben Sie diesen Code manuell in Ihrer Authenticator-App ein:",
"2. Enter the 6-digit code from your authenticator": "2. Geben Sie den 6-stelligen Code aus Ihrem Authenticator ein",
"Verify and enable": "Überprüfen und aktivieren",
"Failed to generate QR code. Please try again.": "Fehler beim Generieren des QR-Codes. Bitte versuchen Sie es erneut.",
"Backup": "Sicherung",
"Save codes": "Codes speichern",
"Save your backup codes": "Speichern Sie Ihre Sicherungscodes",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Diese Codes können verwendet werden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authenticator-App verlieren. Jeder Code kann nur einmal verwendet werden.",
"Print": "Drucken",
"Two-factor authentication has been set up. Please log in again.": "Zwei-Faktor-Authentifizierung wurde eingerichtet. Bitte melden Sie sich erneut an.",
"Two-Factor authentication required": "Zwei-Faktor-Authentifizierung erforderlich",
"Your workspace requires two-factor authentication for all users": "Ihr Arbeitsbereich erfordert die Zwei-Faktor-Authentifizierung für alle Benutzer",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Um weiterhin auf Ihren Arbeitsbereich zuzugreifen, müssen Sie die Zwei-Faktor-Authentifizierung einrichten. Dies fügt Ihrem Konto eine zusätzliche Sicherheitsebene hinzu.",
"Set up two-factor authentication": "Zwei-Faktor-Authentifizierung einrichten",
"Cancel and logout": "Abbrechen und abmelden",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Ihr Arbeitsbereich erfordert eine Zwei-Faktor-Authentifizierung. Bitte richten Sie diese ein, um fortzufahren.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Dadurch wird Ihrem Konto eine zusätzliche Sicherheitsebene hinzugefügt, indem ein Bestätigungscode von Ihrer Authenticator-App verlangt wird.",
"Password is required": "Passwort erforderlich",
"Password must be at least 8 characters": "Passwort muss mindestens 8 Zeichen lang sein",
"Please enter a 6-digit code": "Bitte geben Sie einen 6-stelligen Code ein",
"Code must be exactly 6 digits": "Code muss genau 6-stellig sein",
"Enter the 6-digit code found in your authenticator app": "Geben Sie den 6-stelligen Code ein, der in Ihrer Authenticator-App zu finden ist",
"Need help authenticating?": "Brauchen Sie Hilfe bei der Authentifizierung?",
"MFA QR Code": "MFA QR-Code",
"Account created successfully. Please log in to set up two-factor authentication.": "Konto erfolgreich erstellt. Bitte melden Sie sich an, um die Zwei-Faktor-Authentifizierung einzurichten.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an und führen Sie die Zwei-Faktor-Authentifizierung durch.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an, um die Zwei-Faktor-Authentifizierung einzurichten.",
"Password reset was successful. Please log in with your new password.": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
"Two-factor authentication": "Zwei-Faktor-Authentifizierung",
"Use authenticator app instead": "Stattdessen Authenticator-App verwenden",
"Verify backup code": "Sicherungscode überprüfen",
"Use backup code": "Sicherungscode verwenden",
"Enter one of your backup codes": "Geben Sie einen Ihrer Sicherungscodes ein",
"Backup code": "Sicherungscode",
"Enter one of your backup codes. Each backup code can only be used once.": "Geben Sie einen Ihrer Sicherungscodes ein. Jeder Sicherungscode kann nur einmal verwendet werden.",
"Verify": "Überprüfen",
"Trash": "Papierkorb",
"Pages in trash will be permanently deleted after 30 days.": "Seiten im Papierkorb werden nach 30 Tagen endgültig gelöscht.",
"Deleted": "Gelöscht",
"No pages in trash": "Keine Seiten im Papierkorb",
"Permanently delete page?": "Seite endgültig löschen?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Sind Sie sicher, dass Sie '{{title}}' endgültig löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"Restore '{{title}}' and its sub-pages?": "'{{title}}' und seine Unterseiten wiederherstellen?",
"Move to trash": "In den Papierkorb verschieben",
"Move this page to trash?": "Diese Seite in den Papierkorb verschieben?",
"Restore page": "Seite wiederherstellen",
"Page moved to trash": "Seite in den Papierkorb verschoben",
"Page restored successfully": "Seite erfolgreich wiederhergestellt",
"Deleted by": "Gelöscht von",
"Deleted at": "Gelöscht am",
"Preview": "Vorschau",
"Subpages": "Unterseiten",
"Failed to load subpages": "Fehler beim Laden von Unterseiten",
"No subpages": "Keine Unterseiten",
"Subpages (Child pages)": "Unterseiten (Untergeordnete Seiten)",
"List all subpages of the current page": "Alle Unterseiten der aktuellen Seite auflisten",
"Attachments": "Anhänge",
"All spaces": "Alle Bereiche",
"Unknown": "Unbekannt",
"Find a space": "Einen Bereich finden",
"Search in all your spaces": "In all deinen Bereichen suchen",
"Type": "Art",
"Enterprise": "Unternehmen",
"Download attachment": "Anhang herunterladen",
"Allowed email domains": "Erlaubte E-Mail-Domains",
"Only users with email addresses from these domains can signup via SSO.": "Nur Benutzer mit E-Mail-Adressen aus diesen Domains können sich über SSO registrieren.",
"Enter valid domain names separated by comma or space": "Geben Sie gültige Domainnamen ein, durch Kommas oder Leerzeichen getrennt",
"Enforce two-factor authentication": "Erzwingen der Zwei-Faktor-Authentifizierung",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Sobald es erzwungen wird, müssen alle Mitglieder die Zwei-Faktor-Authentifizierung aktivieren, um auf den Arbeitsbereich zugreifen zu können.",
"Toggle MFA enforcement": "Umschalten der MFA-Erzwingung",
"Display name": "Anzeigename",
"Allow signup": "Registrierung erlauben",
"Enabled": "Aktiviert",
"Advanced Settings": "Erweiterte Einstellungen",
"Enable TLS/SSL": "TLS/SSL aktivieren",
"Use secure connection to LDAP server": "Sichere Verbindung zum LDAP-Server verwenden",
"Group sync": "Gruppensynchronisation",
"No SSO providers found.": "Keine SSO-Anbieter gefunden.",
"Delete SSO provider": "SSO-Anbieter löschen",
"Are you sure you want to delete this SSO provider?": "Sind Sie sicher, dass Sie diesen SSO-Anbieter löschen möchten?",
"Action": "Aktion",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}-Konfiguration"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "e.g Space for product team",
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
"Edit": "Edit",
"Read": "Read",
"Edit group": "Edit group",
"Email": "Email",
"Enter a strong password": "Enter a strong password",
@ -495,5 +496,63 @@
"Page restored successfully": "Page restored successfully",
"Deleted by": "Deleted by",
"Deleted at": "Deleted at",
"Preview": "Preview"
"Preview": "Preview",
"Subpages": "Subpages",
"Failed to load subpages": "Failed to load subpages",
"No subpages": "No subpages",
"Subpages (Child pages)": "Subpages (Child pages)",
"List all subpages of the current page": "List all subpages of the current page",
"Attachments": "Attachments",
"All spaces": "All spaces",
"Unknown": "Unknown",
"Find a space": "Find a space",
"Search in all your spaces": "Search in all your spaces",
"Type": "Type",
"Enterprise": "Enterprise",
"Download attachment": "Download attachment",
"Allowed email domains": "Allowed email domains",
"Only users with email addresses from these domains can signup via SSO.": "Only users with email addresses from these domains can signup via SSO.",
"Enter valid domain names separated by comma or space": "Enter valid domain names separated by comma or space",
"Enforce two-factor authentication": "Enforce two-factor authentication",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Once enforced, all members must enable two-factor authentication to access the workspace.",
"Toggle MFA enforcement": "Toggle MFA enforcement",
"Display name": "Display name",
"Allow signup": "Allow signup",
"Enabled": "Enabled",
"Advanced Settings": "Advanced Settings",
"Enable TLS/SSL": "Enable TLS/SSL",
"Use secure connection to LDAP server": "Use secure connection to LDAP server",
"Group sync": "Group sync",
"No SSO providers found.": "No SSO providers found.",
"Delete SSO provider": "Delete SSO provider",
"Are you sure you want to delete this SSO provider?": "Are you sure you want to delete this SSO provider?",
"Action": "Action",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuration",
"Icon": "Icon",
"Upload image": "Upload image",
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"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",
"Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "ej: Espacio para el equipo de producto",
"e.g Space for sales team to collaborate": "ej: Espacio para que el equipo de ventas colabore",
"Edit": "Editar",
"Read": "Leer",
"Edit group": "Editar grupo",
"Email": "Correo electrónico",
"Enter a strong password": "Introduce una contraseña fuerte",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Comentario eliminado con éxito",
"Failed to delete comment": "No se pudo eliminar el comentario",
"Comment resolved successfully": "Comentario resuelto con éxito",
"Comment re-opened successfully": "Comentario reabierto con éxito",
"Comment unresolved successfully": "Comentario no resuelto con éxito",
"Failed to resolve comment": "No se pudo resolver el comentario",
"Resolve comment": "Resolver comentario",
"Unresolve comment": "No resolver comentario",
"Resolve Comment Thread": "Resolver hilo de comentarios",
"Unresolve Comment Thread": "No resolver hilo de comentarios",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "¿Está seguro de que desea resolver este hilo de comentarios? Esto lo marcará como completado.",
"Are you sure you want to unresolve this comment thread?": "¿Está seguro de que desea no resolver este hilo de comentarios?",
"Resolved": "Resuelto",
"No active comments.": "No hay comentarios activos.",
"No resolved comments.": "No hay comentarios resueltos.",
"Revoke invitation": "Revocar invitación",
"Revoke": "Revocar",
"Don't": "No",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Cualquiera con este enlace puede unirse a este espacio de trabajo.",
"Invite link": "Enlace de invitación",
"Copy": "Copiar",
"Copy to space": "Copiar al espacio",
"Copied": "Copiado",
"Duplicate": "Duplicar",
"Select a user": "Seleccionar un usuario",
"Select a group": "Seleccionar un grupo",
"Export all pages and attachments in this space.": "Exportar todas las páginas y archivos adjuntos en este espacio.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
"New update": "Nueva actualización",
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
"Default page edit mode": "Modo de edición de página predeterminado",
"Choose your preferred page edit mode. Avoid accidental edits.": "Elige tu modo de edición de página preferido. Evita ediciones accidentales.",
"Reading": "Leyendo",
"Delete member": "Eliminar miembro",
"Member deleted successfully": "Miembro eliminado con éxito",
"Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.",
@ -384,7 +401,131 @@
"Share deleted successfully": "Compartición eliminada con éxito",
"Share not found": "Compartición no encontrada",
"Failed to share page": "Error al compartir la página",
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully"
"Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página en otro espacio",
"Page copied successfully": "Página copiada exitosamente",
"Page duplicated successfully": "Página duplicada con éxito",
"Find": "Buscar",
"Not found": "No encontrado",
"Previous Match (Shift+Enter)": "Coincidencia anterior (Shift+Enter)",
"Next match (Enter)": "Siguiente coincidencia (Enter)",
"Match case (Alt+C)": "Distinguir mayúsculas y minúsculas (Alt+C)",
"Replace": "Reemplazar",
"Close (Escape)": "Cerrar (Escape)",
"Replace (Enter)": "Reemplazar (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Reemplazar todo (Ctrl+Alt+Enter)",
"Replace all": "Reemplazar todo",
"View all spaces": "Ver todos los espacios",
"Error": "Error",
"Failed to disable MFA": "No se pudo desactivar MFA",
"Disable two-factor authentication": "Desactivar la autenticación de dos factores",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Desactivar la autenticación de dos factores hará que tu cuenta sea menos segura. Solo necesitarás tu contraseña para iniciar sesión.",
"Please enter your password to disable two-factor authentication:": "Por favor ingresa tu contraseña para desactivar la autenticación de dos factores:",
"Two-factor authentication has been enabled": "La autenticación de dos factores ha sido activada",
"Two-factor authentication has been disabled": "La autenticación de dos factores ha sido desactivada",
"2-step verification": "Verificación en 2 pasos",
"Protect your account with an additional verification layer when signing in.": "Protege tu cuenta con una capa adicional de verificación al iniciar sesión.",
"Two-factor authentication is active on your account.": "La autenticación de dos factores está activa en tu cuenta.",
"Add 2FA method": "Agregar método 2FA",
"Backup codes": "Códigos de seguridad",
"Disable": "Desactivar",
"Invalid verification code": "Código de verificación no válido",
"New backup codes have been generated": "Nuevos códigos de seguridad han sido generados",
"Failed to regenerate backup codes": "No se pudo regenerar los códigos de seguridad",
"About backup codes": "Acerca de los códigos de seguridad",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Los códigos de seguridad pueden usarse para acceder a tu cuenta si pierdes acceso a tu aplicación autenticadora. Cada código solo puede ser usado una vez.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Puedes regenerar nuevos códigos de seguridad en cualquier momento. Esto invalidará todos los códigos existentes.",
"Confirm password": "Confirmar contraseña",
"Generate new backup codes": "Generar nuevos códigos de seguridad",
"Save your new backup codes": "Guarda tus nuevos códigos de seguridad",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Asegúrate de guardar estos códigos en un lugar seguro. Tus viejos códigos de seguridad ya no son válidos.",
"Your new backup codes": "Tus nuevos códigos de seguridad",
"I've saved my backup codes": "He guardado mis códigos de seguridad",
"Failed to setup MFA": "No se pudo configurar MFA",
"Setup & Verify": "Configurar y verificar",
"Add to authenticator": "Agregar al autenticador",
"1. Scan this QR code with your authenticator app": "1. Escanea este código QR con tu aplicación autenticadora",
"Can't scan the code?": "¿No puedes escanear el código?",
"Enter this code manually in your authenticator app:": "Introduce este código manualmente en tu aplicación autenticadora:",
"2. Enter the 6-digit code from your authenticator": "2. Introduce el código de 6 dígitos de tu autenticador",
"Verify and enable": "Verificar y activar",
"Failed to generate QR code. Please try again.": "No se pudo generar el código QR. Por favor, intente de nuevo.",
"Backup": "Respaldo",
"Save codes": "Guardar códigos",
"Save your backup codes": "Guarda tus códigos de seguridad",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Estos códigos pueden usarse para acceder a tu cuenta si pierdes acceso a tu aplicación autenticadora. Cada código solo puede ser usado una vez.",
"Print": "Imprimir",
"Two-factor authentication has been set up. Please log in again.": "La autenticación de dos factores ha sido configurada. Por favor, inicie sesión nuevamente.",
"Two-Factor authentication required": "Se requiere autenticación de dos factores",
"Your workspace requires two-factor authentication for all users": "Tu espacio de trabajo requiere autenticación de dos factores para todos los usuarios",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Para continuar accediendo a tu espacio de trabajo, debes configurar la autenticación de dos factores. Esto añade una capa extra de seguridad a tu cuenta.",
"Set up two-factor authentication": "Configurar la autenticación de dos factores",
"Cancel and logout": "Cancelar y cerrar sesión",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Tu espacio de trabajo requiere autenticación de dos factores. Por favor, configúralo para continuar.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Esto añade una capa extra de seguridad a tu cuenta al requerir un código de verificación de tu aplicación autenticadora.",
"Password is required": "Se requiere contraseña",
"Password must be at least 8 characters": "La contraseña debe tener al menos 8 caracteres",
"Please enter a 6-digit code": "Por favor, introduce un código de 6 dígitos",
"Code must be exactly 6 digits": "El código debe ser exactamente de 6 dígitos",
"Enter the 6-digit code found in your authenticator app": "Introduce el código de 6 dígitos que se encuentra en tu aplicación autenticadora",
"Need help authenticating?": "¿Necesitas ayuda para autenticar?",
"MFA QR Code": "Código QR MFA",
"Account created successfully. Please log in to set up two-factor authentication.": "Cuenta creada exitosamente. Por favor, inicie sesión para configurar la autenticación de dos factores.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Restablecimiento de contraseña exitoso. Por favor, inicie sesión con su nueva contraseña y complete la autenticación de dos factores.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Restablecimiento de contraseña exitoso. Por favor, inicie sesión con su nueva contraseña para configurar la autenticación de dos factores.",
"Password reset was successful. Please log in with your new password.": "El restablecimiento de contraseña fue exitoso. Por favor, inicie sesión con su nueva contraseña.",
"Two-factor authentication": "Autenticación de dos factores",
"Use authenticator app instead": "Usar la aplicación autenticadora en su lugar",
"Verify backup code": "Verificar código de seguridad",
"Use backup code": "Usar código de seguridad",
"Enter one of your backup codes": "Introduce uno de tus códigos de seguridad",
"Backup code": "Código de seguridad",
"Enter one of your backup codes. Each backup code can only be used once.": "Introduce uno de tus códigos de seguridad. Cada código de seguridad solo puede ser usado una vez.",
"Verify": "Verificar",
"Trash": "Papelera",
"Pages in trash will be permanently deleted after 30 days.": "Las páginas en la papelera serán eliminadas permanentemente después de 30 días.",
"Deleted": "Eliminado",
"No pages in trash": "No hay páginas en la papelera",
"Permanently delete page?": "¿Eliminar página permanentemente?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "¿Está seguro de que desea eliminar '{{title}}' permanentemente? Esta acción no se puede deshacer.",
"Restore '{{title}}' and its sub-pages?": "¿Restaurar '{{title}}' y sus subpáginas?",
"Move to trash": "Mover a la papelera",
"Move this page to trash?": "¿Mover esta página a la papelera?",
"Restore page": "Restaurar página",
"Page moved to trash": "Página movida a la papelera",
"Page restored successfully": "Página restaurada con éxito",
"Deleted by": "Eliminado por",
"Deleted at": "Eliminado en",
"Preview": "Vista previa",
"Subpages": "Subpáginas",
"Failed to load subpages": "Error al cargar subpáginas",
"No subpages": "Sin subpáginas",
"Subpages (Child pages)": "Subpáginas (Páginas hijas)",
"List all subpages of the current page": "Listar todas las subpáginas de la página actual",
"Attachments": "Adjuntos",
"All spaces": "Todos los espacios",
"Unknown": "Desconocido",
"Find a space": "Encontrar un espacio",
"Search in all your spaces": "Buscar en todos tus espacios",
"Type": "Tipo",
"Enterprise": "Empresa",
"Download attachment": "Descargar adjunto",
"Allowed email domains": "Dominios de correo electrónico permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Solo los usuarios con direcciones de correo electrónico de estos dominios pueden registrarse a través de SSO.",
"Enter valid domain names separated by comma or space": "Introduce nombres de dominio válidos separados por coma o espacio",
"Enforce two-factor authentication": "Aplicar autenticación de dos factores",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Una vez aplicada, todos los miembros deben habilitar la autenticación de dos factores para acceder al espacio de trabajo.",
"Toggle MFA enforcement": "Alternar la aplicación de MFA",
"Display name": "Nombre para mostrar",
"Allow signup": "Permitir registro",
"Enabled": "Habilitado",
"Advanced Settings": "Configuración avanzada",
"Enable TLS/SSL": "Habilitar TLS/SSL",
"Use secure connection to LDAP server": "Usar conexión segura al servidor LDAP",
"Group sync": "Sincronización de grupos",
"No SSO providers found.": "No se encontraron proveedores de SSO.",
"Delete SSO provider": "Eliminar proveedor de SSO",
"Are you sure you want to delete this SSO provider?": "¿Está seguro de que desea eliminar este proveedor de SSO?",
"Action": "Acción",
"{{ssoProviderType}} configuration": "Configuración de {{ssoProviderType}}"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "par ex. Espace pour l'équipe produit",
"e.g Space for sales team to collaborate": "par ex. Espace pour l'équipe de vente pour collaborer",
"Edit": "Modifier",
"Read": "Lire",
"Edit group": "Modifier groupe",
"Email": "Email",
"Enter a strong password": "Entrez un mot de passe fort",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Commentaire supprimé avec succès",
"Failed to delete comment": "Échec de la suppression du commentaire",
"Comment resolved successfully": "Commentaire résolu avec succès",
"Comment re-opened successfully": "Commentaire rouvert avec succès",
"Comment unresolved successfully": "Commentaire non résolu avec succès",
"Failed to resolve comment": "Échec de la résolution du commentaire",
"Resolve comment": "Résoudre le commentaire",
"Unresolve comment": "Désorganiser le commentaire",
"Resolve Comment Thread": "Résoudre le fil de commentaires",
"Unresolve Comment Thread": "Désorganiser le fil de commentaires",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Êtes-vous sûr de vouloir résoudre ce fil de commentaires ? Cela le marquera comme terminé.",
"Are you sure you want to unresolve this comment thread?": "Êtes-vous sûr de vouloir désorganiser ce fil de commentaires ?",
"Resolved": "Résolu",
"No active comments.": "Aucun commentaire actif.",
"No resolved comments.": "Aucun commentaire résolu.",
"Revoke invitation": "Révoquer l'invitation",
"Revoke": "Révoquer",
"Don't": "Ne pas",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Toute personne ayant ce lien peut rejoindre cet espace de travail.",
"Invite link": "Lien d'invitation",
"Copy": "Copier",
"Copy to space": "Copier dans l'espace",
"Copied": "Copié",
"Duplicate": "Dupliquer",
"Select a user": "Sélectionner un utilisateur",
"Select a group": "Sélectionner un groupe",
"Export all pages and attachments in this space.": "Exporter toutes les pages et pièces jointes dans cet espace.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
"New update": "Nouvelle mise à jour",
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
"Default page edit mode": "Mode d'édition de page par défaut",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choisissez votre mode d'édition de page préféré. Évitez les modifications accidentelles.",
"Reading": "Lecture",
"Delete member": "Supprimer le membre",
"Member deleted successfully": "Membre supprimé avec succès",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.",
@ -386,5 +403,129 @@
"Failed to share page": "Échec du partage de la page",
"Copy page": "Copier la page",
"Copy page to a different space.": "Copier la page dans un autre espace.",
"Page copied successfully": "Page copiée avec succès"
"Page copied successfully": "Page copiée avec succès",
"Page duplicated successfully": "Page dupliquée avec succès",
"Find": "Trouver",
"Not found": "Non trouvé",
"Previous Match (Shift+Enter)": "Correspondance précédente (Shift+Entrée)",
"Next match (Enter)": "Correspondance suivante (Entrée)",
"Match case (Alt+C)": "Respecter la casse (Alt+C)",
"Replace": "Remplacer",
"Close (Escape)": "Fermer (Échapper)",
"Replace (Enter)": "Remplacer (Entrée)",
"Replace all (Ctrl+Alt+Enter)": "Tout remplacer (Ctrl+Alt+Entrée)",
"Replace all": "Tout remplacer",
"View all spaces": "Voir tous les espaces",
"Error": "Erreur",
"Failed to disable MFA": "Impossible de désactiver l'A2F",
"Disable two-factor authentication": "Désactiver l'authentification à deux facteurs",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "La désactivation de l'authentification à deux facteurs rendra votre compte moins sécurisé. Vous n'aurez besoin que de votre mot de passe pour vous connecter.",
"Please enter your password to disable two-factor authentication:": "Veuillez entrer votre mot de passe pour désactiver l'authentification à deux facteurs :",
"Two-factor authentication has been enabled": "L'authentification à deux facteurs a été activée",
"Two-factor authentication has been disabled": "L'authentification à deux facteurs a été désactivée",
"2-step verification": "Vérification en 2 étapes",
"Protect your account with an additional verification layer when signing in.": "Protégez votre compte avec une couche de vérification supplémentaire lors de la connexion.",
"Two-factor authentication is active on your account.": "L'authentification à deux facteurs est active sur votre compte.",
"Add 2FA method": "Ajouter une méthode A2F",
"Backup codes": "Codes de sauvegarde",
"Disable": "Désactiver",
"Invalid verification code": "Code de vérification invalide",
"New backup codes have been generated": "De nouveaux codes de sauvegarde ont été générés",
"Failed to regenerate backup codes": "Échec de la régénération des codes de sauvegarde",
"About backup codes": "À propos des codes de sauvegarde",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Les codes de sauvegarde peuvent être utilisés pour accéder à votre compte si vous perdez l'accès à votre application d'authentification. Chaque code ne peut être utilisé qu'une seule fois.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Vous pouvez régénérer de nouveaux codes de sauvegarde à tout moment. Cela invalidera tous les codes existants.",
"Confirm password": "Confirmer le mot de passe",
"Generate new backup codes": "Générer de nouveaux codes de sauvegarde",
"Save your new backup codes": "Enregistrez vos nouveaux codes de sauvegarde",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Assurez-vous d'enregistrer ces codes dans un endroit sécurisé. Vos anciens codes de sauvegarde ne sont plus valides.",
"Your new backup codes": "Vos nouveaux codes de sauvegarde",
"I've saved my backup codes": "J'ai enregistré mes codes de sauvegarde",
"Failed to setup MFA": "Échec de la configuration de l'A2F",
"Setup & Verify": "Configurer et vérifier",
"Add to authenticator": "Ajouter à l'authentification",
"1. Scan this QR code with your authenticator app": "1. Scannez ce code QR avec votre application d'authentification",
"Can't scan the code?": "Impossible de scanner le code ?",
"Enter this code manually in your authenticator app:": "Entrez ce code manuellement dans votre application d'authentification :",
"2. Enter the 6-digit code from your authenticator": "2. Entrez le code à 6 chiffres de votre authentificateur",
"Verify and enable": "Vérifier et activer",
"Failed to generate QR code. Please try again.": "Échec de la génération du code QR. Veuillez réessayer.",
"Backup": "Sauvegarde",
"Save codes": "Enregistrer les codes",
"Save your backup codes": "Enregistrez vos codes de sauvegarde",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Ces codes peuvent être utilisés pour accéder à votre compte si vous perdez l'accès à votre application d'authentification. Chaque code ne peut être utilisé qu'une seule fois.",
"Print": "Imprimer",
"Two-factor authentication has been set up. Please log in again.": "L'authentification à deux facteurs a été configurée. Veuillez vous reconnecter.",
"Two-Factor authentication required": "Authentification à deux facteurs requise",
"Your workspace requires two-factor authentication for all users": "Votre espace de travail nécessite l'authentification à deux facteurs pour tous les utilisateurs",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Pour continuer à accéder à votre espace de travail, vous devez configurer l'authentification à deux facteurs. Cela ajoute une couche de sécurité supplémentaire à votre compte.",
"Set up two-factor authentication": "Configurer l'authentification à deux facteurs",
"Cancel and logout": "Annuler et se déconnecter",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Votre espace de travail nécessite l'authentification à deux facteurs. Veuillez le configurer pour continuer.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Cela ajoute une couche de sécurité supplémentaire à votre compte en exigeant un code de vérification provenant de votre application d'authentification.",
"Password is required": "Mot de passe requis",
"Password must be at least 8 characters": "Le mot de passe doit comporter au moins 8 caractères",
"Please enter a 6-digit code": "Veuillez entrer un code à 6 chiffres",
"Code must be exactly 6 digits": "Le code doit être exactement de 6 chiffres",
"Enter the 6-digit code found in your authenticator app": "Entrez le code à 6 chiffres trouvé dans votre application d'authentification",
"Need help authenticating?": "Besoin d'aide pour l'authentification ?",
"MFA QR Code": "Code QR de l'A2F",
"Account created successfully. Please log in to set up two-factor authentication.": "Compte créé avec succès. Veuillez vous connecter pour configurer l'authentification à deux facteurs.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Réinitialisation du mot de passe réussie. Veuillez vous connecter avec votre nouveau mot de passe et compléter l'authentification à deux facteurs.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Réinitialisation du mot de passe réussie. Veuillez vous connecter avec votre nouveau mot de passe pour configurer l'authentification à deux facteurs.",
"Password reset was successful. Please log in with your new password.": "La réinitialisation du mot de passe a réussi. Veuillez vous connecter avec votre nouveau mot de passe.",
"Two-factor authentication": "Authentification à deux facteurs",
"Use authenticator app instead": "Utilisez l'application d'authentification à la place",
"Verify backup code": "Vérifier le code de sauvegarde",
"Use backup code": "Utiliser le code de sauvegarde",
"Enter one of your backup codes": "Entrez un de vos codes de sauvegarde",
"Backup code": "Code de sauvegarde",
"Enter one of your backup codes. Each backup code can only be used once.": "Entrez un de vos codes de sauvegarde. Chaque code de sauvegarde ne peut être utilisé qu'une seule fois.",
"Verify": "Vérifier",
"Trash": "Corbeille",
"Pages in trash will be permanently deleted after 30 days.": "Les pages dans la corbeille seront définitivement supprimées après 30 jours.",
"Deleted": "Supprimé",
"No pages in trash": "Aucune page dans la corbeille",
"Permanently delete page?": "Supprimer définitivement la page ?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer définitivement « {{title}} » ? Cette action ne peut pas être annulée.",
"Restore '{{title}}' and its sub-pages?": "Restaurer « {{title}} » et ses sous-pages ?",
"Move to trash": "Déplacer vers la corbeille",
"Move this page to trash?": "Déplacer cette page vers la corbeille ?",
"Restore page": "Restaurer la page",
"Page moved to trash": "Page déplacée vers la corbeille",
"Page restored successfully": "Page restaurée avec succès",
"Deleted by": "Supprimé par",
"Deleted at": "Supprimé à",
"Preview": "Aperçu",
"Subpages": "Sous-pages",
"Failed to load subpages": "Échec du chargement des sous-pages",
"No subpages": "Pas de sous-pages",
"Subpages (Child pages)": "Sous-pages (Pages enfants)",
"List all subpages of the current page": "Lister toutes les sous-pages de la page actuelle",
"Attachments": "Pièces jointes",
"All spaces": "Tous les espaces",
"Unknown": "Inconnu",
"Find a space": "Trouver un espace",
"Search in all your spaces": "Rechercher dans tous vos espaces",
"Type": "Type",
"Enterprise": "Entreprise",
"Download attachment": "Télécharger la pièce jointe",
"Allowed email domains": "Domaines de messagerie autorisés",
"Only users with email addresses from these domains can signup via SSO.": "Seuls les utilisateurs possédant des adresses e-mail provenant de ces domaines peuvent s'inscrire via SSO.",
"Enter valid domain names separated by comma or space": "Entrez des noms de domaine valides séparés par une virgule ou un espace",
"Enforce two-factor authentication": "Imposer l'authentification à deux facteurs",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Une fois appliquée, tous les membres doivent activer l'authentification à deux facteurs pour accéder à l'espace de travail.",
"Toggle MFA enforcement": "Basculer l'application de l'AMF",
"Display name": "Nom d'affichage",
"Allow signup": "Autoriser l'inscription",
"Enabled": "Activé",
"Advanced Settings": "Paramètres avancés",
"Enable TLS/SSL": "Activer TLS/SSL",
"Use secure connection to LDAP server": "Utiliser une connexion sécurisée au serveur LDAP",
"Group sync": "Synchronisation de groupe",
"No SSO providers found.": "Aucun fournisseur SSO trouvé.",
"Delete SSO provider": "Supprimer le fournisseur SSO",
"Are you sure you want to delete this SSO provider?": "Êtes-vous sûr de vouloir supprimer ce fournisseur SSO ?",
"Action": "Action",
"{{ssoProviderType}} configuration": "Configuration {{ssoProviderType}}"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "es. Spazio per il team di prodotto",
"e.g Space for sales team to collaborate": "es. Spazio per la collaborazione del team di vendita",
"Edit": "Modifica",
"Read": "Leggi",
"Edit group": "Modifica gruppo",
"Email": "Email",
"Enter a strong password": "Inserisci una password sicura",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Commento eliminato con successo",
"Failed to delete comment": "Impossibile eliminare il commento",
"Comment resolved successfully": "Commento risolto con successo",
"Comment re-opened successfully": "Commento riaperto con successo",
"Comment unresolved successfully": "Commento non risolto con successo",
"Failed to resolve comment": "Impossibile risolvere il commento",
"Resolve comment": "Risolvi commento",
"Unresolve comment": "Annulla risoluzione commento",
"Resolve Comment Thread": "Risolvi discussione commenti",
"Unresolve Comment Thread": "Annulla risoluzione discussione commenti",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Sei sicuro di voler risolvere questa discussione di commenti? Questo la contrassegnerà come completata.",
"Are you sure you want to unresolve this comment thread?": "Sei sicuro di voler annullare la risoluzione di questa discussione di commenti?",
"Resolved": "Risolto",
"No active comments.": "Nessun commento attivo.",
"No resolved comments.": "Nessun commento risolto.",
"Revoke invitation": "Revoca invito",
"Revoke": "Revoca",
"Don't": "Non",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questa area di lavoro.",
"Invite link": "Link d'invito",
"Copy": "Copia",
"Copy to space": "Copia nello spazio",
"Copied": "Copiato",
"Duplicate": "Duplica",
"Select a user": "Seleziona un utente",
"Select a group": "Seleziona un gruppo",
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati di questo spazio.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
"New update": "Nuovo aggiornamento",
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
"Default page edit mode": "Modalità di modifica pagina predefinita",
"Choose your preferred page edit mode. Avoid accidental edits.": "Scegli la tua modalità di modifica della pagina preferita. Evita modifiche accidentali.",
"Reading": "Lettura",
"Delete member": "Elimina membro",
"Member deleted successfully": "Membro eliminato con successo",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
@ -386,5 +403,129 @@
"Failed to share page": "Condivisione della pagina fallita",
"Copy page": "Copia pagina",
"Copy page to a different space.": "Copia pagina in un altro spazio.",
"Page copied successfully": "Pagina copiata con successo"
"Page copied successfully": "Pagina copiata con successo",
"Page duplicated successfully": "Pagina duplicata con successo",
"Find": "Trova",
"Not found": "Non trovato",
"Previous Match (Shift+Enter)": "Corrispondenza precedente (Shift+Invio)",
"Next match (Enter)": "Corrispondenza successiva (Invio)",
"Match case (Alt+C)": "Maiuscole/minuscole (Alt+C)",
"Replace": "Sostituisci",
"Close (Escape)": "Chiudi (Esc)",
"Replace (Enter)": "Sostituisci (Invio)",
"Replace all (Ctrl+Alt+Enter)": "Sostituisci tutto (Ctrl+Alt+Invio)",
"Replace all": "Sostituisci tutto",
"View all spaces": "Visualizza tutti gli spazi",
"Error": "Errore",
"Failed to disable MFA": "Disabilitazione MFA non riuscita",
"Disable two-factor authentication": "Disabilita autenticazione a due fattori",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Disabilitare l'autenticazione a due fattori renderà il tuo account meno sicuro. Avrai bisogno solo della tua password per accedere.",
"Please enter your password to disable two-factor authentication:": "Inserisci la tua password per disabilitare l'autenticazione a due fattori:",
"Two-factor authentication has been enabled": "Autenticazione a due fattori abilitata",
"Two-factor authentication has been disabled": "Autenticazione a due fattori disabilitata",
"2-step verification": "Verifica in 2 passaggi",
"Protect your account with an additional verification layer when signing in.": "Proteggi il tuo account con un ulteriore livello di verifica durante l'accesso.",
"Two-factor authentication is active on your account.": "L'autenticazione a due fattori è attiva sul tuo account.",
"Add 2FA method": "Aggiungi metodo 2FA",
"Backup codes": "Codici di backup",
"Disable": "Disabilita",
"Invalid verification code": "Codice di verifica non valido",
"New backup codes have been generated": "Nuovi codici di backup generati",
"Failed to regenerate backup codes": "Rigenerazione codici di backup non riuscita",
"About backup codes": "Informazioni sui codici di backup",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "I codici di backup possono essere utilizzati per accedere al tuo account se perdi l'accesso alla tua app di autenticazione. Ogni codice può essere usato solo una volta.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Puoi rigenerare nuovi codici di backup in qualsiasi momento. Questo invaliderà tutti i codici esistenti.",
"Confirm password": "Conferma password",
"Generate new backup codes": "Genera nuovi codici di backup",
"Save your new backup codes": "Salva i tuoi nuovi codici di backup",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Assicurati di salvare questi codici in un luogo sicuro. I tuoi vecchi codici di backup non sono più validi.",
"Your new backup codes": "I tuoi nuovi codici di backup",
"I've saved my backup codes": "Ho salvato i miei codici di backup",
"Failed to setup MFA": "Impostazione MFA non riuscita",
"Setup & Verify": "Imposta e Verifica",
"Add to authenticator": "Aggiungi ad authenticator",
"1. Scan this QR code with your authenticator app": "1. Scansiona questo codice QR con la tua app di autenticazione",
"Can't scan the code?": "Non riesci a scansionare il codice?",
"Enter this code manually in your authenticator app:": "Inserisci questo codice manualmente nella tua app di autenticazione:",
"2. Enter the 6-digit code from your authenticator": "2. Inserisci il codice a 6 cifre dal tuo autenticatore",
"Verify and enable": "Verifica e abilita",
"Failed to generate QR code. Please try again.": "Generazione del codice QR non riuscita. Si prega di riprovare.",
"Backup": "Backup",
"Save codes": "Salva codici",
"Save your backup codes": "Salva i tuoi codici di backup",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Questi codici possono essere utilizzati per accedere al tuo account se perdi l'accesso alla tua app di autenticazione. Ogni codice può essere usato solo una volta.",
"Print": "Stampa",
"Two-factor authentication has been set up. Please log in again.": "L'autenticazione a due fattori è stata impostata. Effettua nuovamente l'accesso, per favore.",
"Two-Factor authentication required": "Autenticazione a due fattori richiesta",
"Your workspace requires two-factor authentication for all users": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori per tutti gli utenti",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Per continuare ad accedere al tuo spazio di lavoro, devi impostare l'autenticazione a due fattori. Questo aggiunge un ulteriore livello di sicurezza al tuo account.",
"Set up two-factor authentication": "Imposta l'autenticazione a due fattori",
"Cancel and logout": "Annulla e disconnetti",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Il tuo spazio di lavoro richiede l'autenticazione a due fattori. Impostala per continuare.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Questo aggiunge un ulteriore livello di sicurezza al tuo account richiedendo un codice di verifica dalla tua app di autenticazione.",
"Password is required": "La password è richiesta",
"Password must be at least 8 characters": "La password deve essere di almeno 8 caratteri",
"Please enter a 6-digit code": "Inserisci un codice a 6 cifre",
"Code must be exactly 6 digits": "Il codice deve essere esattamente di 6 cifre",
"Enter the 6-digit code found in your authenticator app": "Inserisci il codice a 6 cifre trovato nella tua app di autenticazione",
"Need help authenticating?": "Hai bisogno di aiuto per autenticarti?",
"MFA QR Code": "Codice QR MFA",
"Account created successfully. Please log in to set up two-factor authentication.": "Account creato con successo. Effettua l'accesso per impostare l'autenticazione a due fattori.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Reimpostazione della password riuscita. Accedi con la tua nuova password e completa l'autenticazione a due fattori.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Reimpostazione della password riuscita. Accedi con la tua nuova password per impostare l'autenticazione a due fattori.",
"Password reset was successful. Please log in with your new password.": "Reimpostazione della password riuscita. Accedi con la tua nuova password.",
"Two-factor authentication": "Autenticazione a due fattori",
"Use authenticator app instead": "Usa l'app di autenticazione invece",
"Verify backup code": "Verifica codice di backup",
"Use backup code": "Usa codice di backup",
"Enter one of your backup codes": "Inserisci uno dei tuoi codici di backup",
"Backup code": "Codice di backup",
"Enter one of your backup codes. Each backup code can only be used once.": "Inserisci uno dei tuoi codici di backup. Ogni codice di backup può essere utilizzato solo una volta.",
"Verify": "Verifica",
"Trash": "Cestino",
"Pages in trash will be permanently deleted after 30 days.": "Le pagine nel cestino verranno eliminate definitivamente dopo 30 giorni.",
"Deleted": "Eliminato",
"No pages in trash": "Nessuna pagina nel cestino",
"Permanently delete page?": "Eliminare definitivamente la pagina?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Sei sicuro di voler eliminare definitivamente '{{title}}'? Questa azione non può essere annullata.",
"Restore '{{title}}' and its sub-pages?": "Ripristinare '{{title}}' e le sue sottopagine?",
"Move to trash": "Sposta nel cestino",
"Move this page to trash?": "Spostare questa pagina nel cestino?",
"Restore page": "Ripristina pagina",
"Page moved to trash": "Pagina spostata nel cestino",
"Page restored successfully": "Pagina ripristinata con successo",
"Deleted by": "Eliminato da",
"Deleted at": "Eliminato il",
"Preview": "Anteprima",
"Subpages": "Sottopagine",
"Failed to load subpages": "Caricamento delle sottopagine non riuscito",
"No subpages": "Nessuna sottopagina",
"Subpages (Child pages)": "Sottopagine (Pagine figlie)",
"List all subpages of the current page": "Elenca tutte le sottopagine della pagina corrente",
"Attachments": "Allegati",
"All spaces": "Tutti gli spazi",
"Unknown": "Sconosciuto",
"Find a space": "Trova uno spazio",
"Search in all your spaces": "Cerca in tutti i tuoi spazi",
"Type": "Tipo",
"Enterprise": "Impresa",
"Download attachment": "Scarica allegato",
"Allowed email domains": "Domini email consentiti",
"Only users with email addresses from these domains can signup via SSO.": "Solo gli utenti con indirizzi email provenienti da questi domini possono registrarsi tramite SSO.",
"Enter valid domain names separated by comma or space": "Inserisci nomi di dominio validi separati da virgole o spazi",
"Enforce two-factor authentication": "Imponi l'autenticazione a due fattori",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Una volta impostata, tutti i membri devono abilitare l'autenticazione a due fattori per accedere all'area di lavoro.",
"Toggle MFA enforcement": "Attiva disattiva l'applicazione MFA",
"Display name": "Nome visualizzato",
"Allow signup": "Consenti iscrizione",
"Enabled": "Abilitato",
"Advanced Settings": "Impostazioni avanzate",
"Enable TLS/SSL": "Abilita TLS/SSL",
"Use secure connection to LDAP server": "Usa connessione sicura al server LDAP",
"Group sync": "Sincronizzazione gruppi",
"No SSO providers found.": "Nessun provider SSO trovato.",
"Delete SSO provider": "Elimina provider SSO",
"Are you sure you want to delete this SSO provider?": "Sei sicuro di voler eliminare questo provider SSO?",
"Action": "Azione",
"{{ssoProviderType}} configuration": "Configurazione {{ssoProviderType}}"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "例: 製品チームのスペース",
"e.g Space for sales team to collaborate": "例: 営業チーム連携用スペース",
"Edit": "編集",
"Read": "読む",
"Edit group": "グループを編集",
"Email": "メールアドレス",
"Enter a strong password": "強力なパスワードを入力してください",
@ -213,7 +214,18 @@
"Comment deleted successfully": "コメントが削除されました",
"Failed to delete comment": "コメントの削除に失敗しました",
"Comment resolved successfully": "コメントが解決されました",
"Comment re-opened successfully": "コメントが再開されました",
"Comment unresolved successfully": "コメントが再解決されました",
"Failed to resolve comment": "コメントの解決に失敗しました",
"Resolve comment": "コメントを解決",
"Unresolve comment": "コメントを再解決",
"Resolve Comment Thread": "コメントスレッドを解決",
"Unresolve Comment Thread": "コメントスレッドを再解決",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "このコメントスレッドを解決しますか? これにより完了としてマークされます。",
"Are you sure you want to unresolve this comment thread?": "このコメントスレッドを再解決しますか?",
"Resolved": "解決済",
"No active comments.": "アクティブなコメントはありません。",
"No resolved comments.": "解決されたコメントはありません。",
"Revoke invitation": "招待を取り消す",
"Revoke": "取り消す",
"Don't": "取り消さない",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "このリンクを持っている人は誰でもこのワークスペースに参加できます。",
"Invite link": "招待リンク",
"Copy": "コピー",
"Copy to space": "スペースにコピー",
"Copied": "コピーしました",
"Duplicate": "複製",
"Select a user": "ユーザを選択",
"Select a group": "グループを選択",
"Export all pages and attachments in this space.": "このスペースのすべてのページと添付ファイルをエクスポートします。",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
"Default page edit mode": "デフォルトのページ編集モード",
"Choose your preferred page edit mode. Avoid accidental edits.": "希望のページ編集モードを選択してください。誤って編集を防ぎます。",
"Reading": "読み取り",
"Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバーが削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
@ -386,5 +403,129 @@
"Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします。",
"Page copied successfully": "ページのコピーに成功しました"
"Page copied successfully": "ページのコピーに成功しました",
"Page duplicated successfully": "ページが正常に複製されました",
"Find": "検索",
"Not found": "見つかりません",
"Previous Match (Shift+Enter)": "前の一致 (Shift+Enter)",
"Next match (Enter)": "次の一致 (Enter)",
"Match case (Alt+C)": "大文字小文字を区別 (Alt+C)",
"Replace": "置換",
"Close (Escape)": "閉じる (Escape)",
"Replace (Enter)": "置換 (Enter)",
"Replace all (Ctrl+Alt+Enter)": "すべて置換 (Ctrl+Alt+Enter)",
"Replace all": "すべて置換",
"View all spaces": "すべてのスペースを表示",
"Error": "エラー",
"Failed to disable MFA": "MFAの無効化に失敗しました",
"Disable two-factor authentication": "二要素認証を無効化",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "二要素認証を無効化すると、アカウントのセキュリティが低下します。サインインにはパスワードのみが必要になります。",
"Please enter your password to disable two-factor authentication:": "二要素認証を無効化するにはパスワードを入力してください:",
"Two-factor authentication has been enabled": "二要素認証が有効になりました",
"Two-factor authentication has been disabled": "二要素認証が無効になりました",
"2-step verification": "2段階確認",
"Protect your account with an additional verification layer when signing in.": "サインイン時に追加の認証レイヤーでアカウントを保護します。",
"Two-factor authentication is active on your account.": "二要素認証がアカウントで有効です。",
"Add 2FA method": "2FAメソッドを追加",
"Backup codes": "バックアップコード",
"Disable": "無効にする",
"Invalid verification code": "無効な認証コード",
"New backup codes have been generated": "新しいバックアップコードが生成されました",
"Failed to regenerate backup codes": "バックアップコードの再生成に失敗しました",
"About backup codes": "バックアップコードについて",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "バックアップコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "いつでも新しいバックアップコードを再生成できます。これにより、既存のすべてのコードが無効になります。",
"Confirm password": "パスワードを確認",
"Generate new backup codes": "新しいバックアップコードを生成",
"Save your new backup codes": "新しいバックアップコードを保存",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "これらのコードを安全な場所に保存してください。古いバックアップコードは無効です。",
"Your new backup codes": "新しいバックアップコード",
"I've saved my backup codes": "バックアップコードを保存しました",
"Failed to setup MFA": "MFAの設定に失敗しました",
"Setup & Verify": "設定と確認",
"Add to authenticator": "認証アプリに追加",
"1. Scan this QR code with your authenticator app": "1. このQRコードを認証アプリでスキャンしてください",
"Can't scan the code?": "コードをスキャンできませんか?",
"Enter this code manually in your authenticator app:": "このコードを認証アプリに手動で入力してください:",
"2. Enter the 6-digit code from your authenticator": "2. 認証アプリからの6桁のコードを入力してください",
"Verify and enable": "確認と有効化",
"Failed to generate QR code. Please try again.": "QRコードの生成に失敗しました。再試行してください。",
"Backup": "バックアップ",
"Save codes": "コードを保存",
"Save your backup codes": "バックアップコードを保存",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "これらのコードは、認証アプリへのアクセスを失った場合にアカウントにアクセスするために使用できます。各コードは一度しか使用できません。",
"Print": "印刷",
"Two-factor authentication has been set up. Please log in again.": "二要素認証が設定されました。再度ログインしてください。",
"Two-Factor authentication required": "二要素認証が必要です",
"Your workspace requires two-factor authentication for all users": "ワークスペースでは、すべてのユーザーに二要素認証が必要です",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "ワークスペースへのアクセスを続けるには、二要素認証を設定する必要があります。これにより、アカウントに追加のセキュリティ層が追加されます。",
"Set up two-factor authentication": "二要素認証を設定",
"Cancel and logout": "キャンセルしてログアウト",
"Your workspace requires two-factor authentication. Please set it up to continue.": "ワークスペースでは二要素認証が必要です。続行するには設定してください。",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "これにより、認証アプリからの確認コードが必要となり、アカウントに追加のセキュリティ層が追加されます。",
"Password is required": "パスワードが必要です",
"Password must be at least 8 characters": "パスワードは8文字以上必要です",
"Please enter a 6-digit code": "6桁のコードを入力してください",
"Code must be exactly 6 digits": "コードは正確に6桁である必要があります",
"Enter the 6-digit code found in your authenticator app": "認証アプリに表示された6桁のコードを入力してください",
"Need help authenticating?": "認証に関するヘルプが必要ですか?",
"MFA QR Code": "MFA QRコード",
"Account created successfully. Please log in to set up two-factor authentication.": "アカウントが正常に作成されました。二要素認証を設定するためにログインしてください。",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "パスワードのリセットが成功しました。新しいパスワードでログインし、二要素認証を完了してください。",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "パスワードのリセットが成功しました。二要素認証を設定するために新しいパスワードでログインしてください。",
"Password reset was successful. Please log in with your new password.": "パスワードのリセットが成功しました。新しいパスワードでログインしてください。",
"Two-factor authentication": "二要素認証",
"Use authenticator app instead": "代わりに認証アプリを使用",
"Verify backup code": "バックアップコードを確認",
"Use backup code": "バックアップコードを使用",
"Enter one of your backup codes": "バックアップコードのいずれかを入力してください",
"Backup code": "バックアップコード",
"Enter one of your backup codes. Each backup code can only be used once.": "バックアップコードのいずれかを入力してください。各バックアップコードは一度しか使用できません。",
"Verify": "確認",
"Trash": "ごみ箱",
"Pages in trash will be permanently deleted after 30 days.": "ごみ箱内のページは30日後に完全に削除されます。",
"Deleted": "削除",
"No pages in trash": "ごみ箱にページがありません",
"Permanently delete page?": "ページを完全に削除しますか?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "{{title}}』を完全に削除しますか? この操作は元に戻せません。",
"Restore '{{title}}' and its sub-pages?": "{{title}}』とそのサブページを復元しますか?",
"Move to trash": "ごみ箱に移動",
"Move this page to trash?": "このページをごみ箱に移動しますか?",
"Restore page": "ページを復元",
"Page moved to trash": "ページがごみ箱に移動されました",
"Page restored successfully": "ページが正常に復元されました",
"Deleted by": "削除者",
"Deleted at": "削除日時",
"Preview": "プレビュー",
"Subpages": "サブページ",
"Failed to load subpages": "サブページの読み込みに失敗しました",
"No subpages": "サブページがありません",
"Subpages (Child pages)": "サブページ(子ページ)",
"List all subpages of the current page": "現在のページのすべてのサブページをリスト",
"Attachments": "添付ファイル",
"All spaces": "すべてのスペース",
"Unknown": "不明",
"Find a space": "スペースを探す",
"Search in all your spaces": "あなたのすべてのスペースで検索",
"Type": "タイプ",
"Enterprise": "エンタープライズ",
"Download attachment": "添付ファイルをダウンロード",
"Allowed email domains": "許可されたメールドメイン",
"Only users with email addresses from these domains can signup via SSO.": "これらのドメインからのメールアドレスを持つユーザーのみがSSOで登録できます。",
"Enter valid domain names separated by comma or space": "コンマまたはスペースで区切って有効なドメイン名を入力してください",
"Enforce two-factor authentication": "二要素認証を強制する",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一度強制されると、すべてのメンバーはワークスペースにアクセスするために二要素認証を有効にする必要があります。",
"Toggle MFA enforcement": "MFAの強制を切り替える",
"Display name": "表示名",
"Allow signup": "登録を許可する",
"Enabled": "有効",
"Advanced Settings": "詳細設定",
"Enable TLS/SSL": "TLS/SSLを有効にする",
"Use secure connection to LDAP server": "LDAPサーバーへの安全な接続を使用する",
"Group sync": "グループ同期",
"No SSO providers found.": "SSOプロバイダーが見つかりませんでした。",
"Delete SSO provider": "SSOプロバイダーを削除する",
"Are you sure you want to delete this SSO provider?": "このSSOプロバイダーを削除してもよろしいですか",
"Action": "アクション",
"{{ssoProviderType}} configuration": "{{ssoProviderType}}の構成"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "예: 제품 팀을 위한 Space",
"e.g Space for sales team to collaborate": "예: 영업 팀의 Space",
"Edit": "편집",
"Read": "읽기",
"Edit group": "팀 편집",
"Email": "이메일",
"Enter a strong password": "강력한 비밀번호를 입력하세요",
@ -213,7 +214,18 @@
"Comment deleted successfully": "댓글 삭제 완료",
"Failed to delete comment": "댓글 삭제 실패",
"Comment resolved successfully": "댓글 처리 완료",
"Comment re-opened successfully": "댓글이 성공적으로 다시 열렸습니다",
"Comment unresolved successfully": "댓글 미해결로 변경 완료",
"Failed to resolve comment": "댓글 처리 실패",
"Resolve comment": "댓글 해결하기",
"Unresolve comment": "댓글 미해결로 변경하기",
"Resolve Comment Thread": "댓글 스레드 해결하기",
"Unresolve Comment Thread": "댓글 스레드 미해결로 변경하기",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "이 댓글 스레드를 해결하시겠습니까? 완료로 표시됩니다.",
"Are you sure you want to unresolve this comment thread?": "이 댓글 스레드를 미해결로 변경하시겠습니까?",
"Resolved": "해결됨",
"No active comments.": "활성 댓글이 없습니다.",
"No resolved comments.": "해결된 댓글이 없습니다.",
"Revoke invitation": "초대 취소",
"Revoke": "취소",
"Don't": "하지 않음",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
"Invite link": "초대 링크",
"Copy": "복사",
"Copy to space": "공간에 복사하기",
"Copied": "복사됨",
"Duplicate": "중복",
"Select a user": "사용자 선택",
"Select a group": "팀 선택",
"Export all pages and attachments in this space.": "이 Space의 모든 페이지와 첨부파일을 내보냅니다.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
"New update": "새로운 업데이트",
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
"Default page edit mode": "기본 페이지 편집 모드",
"Choose your preferred page edit mode. Avoid accidental edits.": "선호하는 페이지 편집 모드를 선택하세요. 실수로 인한 편집을 방지하세요.",
"Reading": "읽기",
"Delete member": "회원 삭제",
"Member deleted successfully": "멤버가 성공적으로 제거되었습니다",
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
@ -384,7 +401,131 @@
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share not found": "공유를 찾을 수 없습니다",
"Failed to share page": "페이지 공유에 실패했습니다",
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully"
"Copy page": "페이지 복사하기",
"Copy page to a different space.": "다른 공간으로 페이지 복사하기.",
"Page copied successfully": "페이지가 성공적으로 복사되었습니다",
"Page duplicated successfully": "페이지가 성공적으로 복제되었습니다",
"Find": "찾기",
"Not found": "찾을 수 없음",
"Previous Match (Shift+Enter)": "이전 일치 항목 (Shift+Enter)",
"Next match (Enter)": "다음 일치 항목 (Enter)",
"Match case (Alt+C)": "대소문자 구분 (Alt+C)",
"Replace": "교체",
"Close (Escape)": "닫기 (Escape)",
"Replace (Enter)": "교체 (Enter)",
"Replace all (Ctrl+Alt+Enter)": "모두 교체하기 (Ctrl+Alt+Enter)",
"Replace all": "모두 교체하기",
"View all spaces": "모든 공간 보기",
"Error": "오류",
"Failed to disable MFA": "MFA 비활성화 실패",
"Disable two-factor authentication": "이중 인증 비활성화",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "이중 인증을 비활성화하면 계정의 보안이 낮아집니다. 로그인 시 비밀번호만 필요하게 됩니다.",
"Please enter your password to disable two-factor authentication:": "이중 인증 비활성화를 위해 비밀번호를 입력하세요:",
"Two-factor authentication has been enabled": "이중 인증이 활성화되었습니다",
"Two-factor authentication has been disabled": "이중 인증이 비활성화되었습니다",
"2-step verification": "2단계 인증",
"Protect your account with an additional verification layer when signing in.": "로그인 시 추가 인증 단계를 통해 계정을 보호하세요.",
"Two-factor authentication is active on your account.": "이중 인증이 계정에 활성화되어 있습니다.",
"Add 2FA method": "2FA 방법 추가",
"Backup codes": "백업 코드",
"Disable": "비활성화",
"Invalid verification code": "유효하지 않은 인증 코드",
"New backup codes have been generated": "새 백업 코드가 생성되었습니다",
"Failed to regenerate backup codes": "백업 코드 재생성 실패",
"About backup codes": "백업 코드에 대하여",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 접근할 수 없게 된 경우, 백업 코드를 사용하여 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "언제든지 새 백업 코드를 재생성할 수 있습니다. 이 작업은 기존 모든 코드를 무효화합니다.",
"Confirm password": "비밀번호 확인",
"Generate new backup codes": "새 백업 코드 생성하기",
"Save your new backup codes": "새 백업 코드 저장하기",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "이 코드를 안전한 장소에 저장하세요. 이전 백업 코드는 더 이상 유효하지 않습니다.",
"Your new backup codes": "새 백업 코드",
"I've saved my backup codes": "백업 코드를 저장했습니다",
"Failed to setup MFA": "MFA 설정 실패",
"Setup & Verify": "설정 및 확인",
"Add to authenticator": "인증앱에 추가",
"1. Scan this QR code with your authenticator app": "1. 인증앱으로 이 QR 코드를 스캔하십시오.",
"Can't scan the code?": "코드를 스캔할 수 없습니까?",
"Enter this code manually in your authenticator app:": "이 코드를 인증앱에 수동으로 입력해 주세요:",
"2. Enter the 6-digit code from your authenticator": "2. 인증앱에서 6자리 코드를 입력하십시오",
"Verify and enable": "확인 및 활성화",
"Failed to generate QR code. Please try again.": "QR 코드 생성 실패. 다시 시도해 주세요.",
"Backup": "백업",
"Save codes": "코드 저장",
"Save your backup codes": "백업 코드 저장하기",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "인증 앱에 대한 접근 권한을 잃은 경우, 이 코드를 사용하여 귀하의 계정에 접근할 수 있습니다. 각 코드는 한 번만 사용할 수 있습니다.",
"Print": "인쇄",
"Two-factor authentication has been set up. Please log in again.": "이중 인증이 설정되었습니다. 다시 로그인해 주세요.",
"Two-Factor authentication required": "이중 인증 필요",
"Your workspace requires two-factor authentication for all users": "워크스페이스에서는 모든 사용자에게 이중 인증이 필요합니다.",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "워크스페이스 접근을 계속하려면 이중 인증을 설정해야 합니다. 이는 계정에 추가 보안 계층을 추가합니다.",
"Set up two-factor authentication": "이중 인증 설정하기",
"Cancel and logout": "취소 및 로그아웃",
"Your workspace requires two-factor authentication. Please set it up to continue.": "워크스페이스에서는 이중 인증이 필요합니다. 계속하려면 설정해 주세요.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "인증앱에서 얻은 인증 코드를 요구하여 계정의 보안에 추가적인 계층을 추가합니다.",
"Password is required": "비밀번호가 필요합니다",
"Password must be at least 8 characters": "비밀번호는 최소 8자 이상이어야 합니다",
"Please enter a 6-digit code": "6자리 코드를 입력해 주세요",
"Code must be exactly 6 digits": "코드는 정확히 6자리여야 합니다",
"Enter the 6-digit code found in your authenticator app": "인증앱에서 찾은 6자리 코드를 입력하십시오",
"Need help authenticating?": "인증에 도움이 필요하십니까?",
"MFA QR Code": "MFA QR 코드",
"Account created successfully. Please log in to set up two-factor authentication.": "계정이 성공적으로 생성되었습니다. 이중 인증을 설정하려면 로그인해 주세요.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 완료하세요.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "비밀번호 재설정 성공. 새 비밀번호로 로그인하여 이중 인증을 설정하세요.",
"Password reset was successful. Please log in with your new password.": "비밀번호 재설정이 성공적으로 완료되었습니다. 새 비밀번호로 로그인하세요.",
"Two-factor authentication": "이중 인증",
"Use authenticator app instead": "대신 인증 앱 사용",
"Verify backup code": "백업 코드 확인",
"Use backup code": "백업 코드 사용",
"Enter one of your backup codes": "백업 코드 중 하나를 입력하세요",
"Backup code": "백업 코드",
"Enter one of your backup codes. Each backup code can only be used once.": "백업 코드 중 하나를 입력하세요. 각 백업 코드는 한 번만 사용할 수 있습니다.",
"Verify": "확인",
"Trash": "휴지통",
"Pages in trash will be permanently deleted after 30 days.": "휴지통의 페이지는 30일 후에 영구적으로 삭제됩니다.",
"Deleted": "삭제됨",
"No pages in trash": "휴지통에 페이지가 없습니다",
"Permanently delete page?": "페이지를 영구적으로 삭제하시겠습니까?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "'{{title}}'을(를) 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Restore '{{title}}' and its sub-pages?": "'{{title}}' 및 하위 페이지를 복구하시겠습니까?",
"Move to trash": "휴지통으로 이동",
"Move this page to trash?": "이 페이지를 휴지통으로 이동하시겠습니까?",
"Restore page": "페이지 복구",
"Page moved to trash": "페이지가 휴지통으로 이동되었습니다",
"Page restored successfully": "페이지가 성공적으로 복구되었습니다",
"Deleted by": "삭제자",
"Deleted at": "삭제 시간",
"Preview": "미리보기",
"Subpages": "하위 페이지",
"Failed to load subpages": "하위 페이지 로드 실패",
"No subpages": "하위 페이지 없음",
"Subpages (Child pages)": "하위 페이지 (자식 페이지)",
"List all subpages of the current page": "현재 페이지의 모든 하위 페이지 목록",
"Attachments": "첨부 파일",
"All spaces": "전체 공간",
"Unknown": "알 수 없음",
"Find a space": "공간 찾기",
"Search in all your spaces": "모든 공간에서 검색",
"Type": "유형",
"Enterprise": "기업",
"Download attachment": "첨부 파일 다운로드",
"Allowed email domains": "허용된 이메일 도메인",
"Only users with email addresses from these domains can signup via SSO.": "이 도메인의 이메일 주소를 가진 사용자만 SSO를 통해 가입할 수 있습니다.",
"Enter valid domain names separated by comma or space": "콤마 또는 공백으로 구분하여 유효한 도메인 이름 입력",
"Enforce two-factor authentication": "이중 인증 시행",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "시행되면 모든 멤버가 작업 공간에 액세스하기 위해 이중 인증을 활성화해야 합니다.",
"Toggle MFA enforcement": "MFA 시행 전환",
"Display name": "표시 이름",
"Allow signup": "가입 허용",
"Enabled": "활성화됨",
"Advanced Settings": "고급 설정",
"Enable TLS/SSL": "TLS\\/SSL 활성화",
"Use secure connection to LDAP server": "LDAP 서버에 안전한 연결 사용",
"Group sync": "그룹 동기화",
"No SSO providers found.": "SSO 제공자를 찾을 수 없습니다.",
"Delete SSO provider": "SSO 제공자 삭제",
"Are you sure you want to delete this SSO provider?": "이 SSO 제공자를 삭제하시겠습니까?",
"Action": "작업",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 구성"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "bijv. Ruimte voor productteam",
"e.g Space for sales team to collaborate": "bijv. Ruimte voor verkoopteam om samen te werken",
"Edit": "Bewerken",
"Read": "Lezen",
"Edit group": "Groep bewerken",
"Email": "E-mailadres",
"Enter a strong password": "Voer een sterk wachtwoord in",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Reactie met succes verwijderd",
"Failed to delete comment": "Verwijderen van reactie mislukt",
"Comment resolved successfully": "Reactie succesvol opgelost",
"Comment re-opened successfully": "Reactie succesvol heropend",
"Comment unresolved successfully": "Reactie succesvol niet-opgelost gemaakt",
"Failed to resolve comment": "Reactie oplossen mislukt",
"Resolve comment": "Reactie oplossen",
"Unresolve comment": "Reactie niet oplossen",
"Resolve Comment Thread": "Reactiedraad oplossen",
"Unresolve Comment Thread": "Reactiedraad niet oplossen",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Weet u zeker dat u deze reactiedraad wilt oplossen? Dit zal het als voltooid markeren.",
"Are you sure you want to unresolve this comment thread?": "Weet u zeker dat u deze reactiedraad niet wilt oplossen?",
"Resolved": "Opgelost",
"No active comments.": "Geen actieve reacties.",
"No resolved comments.": "Geen opgeloste reacties.",
"Revoke invitation": "Uitnodiging intrekken",
"Revoke": "Intrekken",
"Don't": "Niet doen",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Iedereen met deze link kan zich aansluiten bij deze werkruimte.",
"Invite link": "Uitnodigingslink",
"Copy": "Kopieer",
"Copy to space": "Kopiëren naar ruimte",
"Copied": "Gekopieerd",
"Duplicate": "Dupliceren",
"Select a user": "Selecteer een gebruiker",
"Select a group": "Selecteer een groep",
"Export all pages and attachments in this space.": "Exporteer alle pagina's en bijlagen in deze ruimte.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}",
"New update": "Nieuwe update",
"{{latestVersion}} is available": "{{latestVersion}} is beschikbaar",
"Default page edit mode": "Standaard pagina bewerkmodus",
"Choose your preferred page edit mode. Avoid accidental edits.": "Kies uw voorkeurs bewerkmodus voor pagina's. Vermijd per ongeluk bewerken.",
"Reading": "Lezen",
"Delete member": "Verwijder lid",
"Member deleted successfully": "Lid succesvol verwijderd",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Weet u zeker dat u dit lid van de werkruimte wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.",
@ -384,7 +401,131 @@
"Share deleted successfully": "Delen succesvol verwijderd",
"Share not found": "Delen niet gevonden",
"Failed to share page": "Pagina delen mislukt",
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully"
"Copy page": "Pagina kopiëren",
"Copy page to a different space.": "Kopieer pagina naar een andere ruimte.",
"Page copied successfully": "Pagina succesvol gekopieerd",
"Page duplicated successfully": "Pagina succesvol gedupliceerd",
"Find": "Zoeken",
"Not found": "Niet gevonden",
"Previous Match (Shift+Enter)": "Vorige overeenkomst (Shift+Enter)",
"Next match (Enter)": "Volgende overeenkomst (Enter)",
"Match case (Alt+C)": "Hoofdlettergevoeligheid (Alt+C)",
"Replace": "Vervangen",
"Close (Escape)": "Sluiten (Escape)",
"Replace (Enter)": "Vervangen (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Alles vervangen (Ctrl+Alt+Enter)",
"Replace all": "Alles vervangen",
"View all spaces": "Bekijk alle ruimtes",
"Error": "Fout",
"Failed to disable MFA": "MFA uitschakelen mislukt",
"Disable two-factor authentication": "Twee-factor authenticatie uitschakelen",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Indien u twee-factor authenticatie uitschakelt, zal uw account minder veilig zijn. U heeft alleen uw wachtwoord nodig om in te loggen.",
"Please enter your password to disable two-factor authentication:": "Voer uw wachtwoord in om twee-factor authenticatie uit te schakelen:",
"Two-factor authentication has been enabled": "Twee-factor authenticatie is ingeschakeld",
"Two-factor authentication has been disabled": "Twee-factor authenticatie is uitgeschakeld",
"2-step verification": "2-staps verificatie",
"Protect your account with an additional verification layer when signing in.": "Bescherm uw account met een extra verificatielaag tijdens het inloggen.",
"Two-factor authentication is active on your account.": "Twee-factor authenticatie is actief op uw account.",
"Add 2FA method": "2FA-methode toevoegen",
"Backup codes": "Back-up codes",
"Disable": "Uitschakelen",
"Invalid verification code": "Ongeldige verificatiecode",
"New backup codes have been generated": "Nieuwe back-up codes zijn gegenereerd",
"Failed to regenerate backup codes": "Back-up codes opnieuw genereren mislukt",
"About backup codes": "Over back-up codes",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Back-up codes kunnen worden gebruikt om uw account te bereiken als u toegang tot uw authenticator-app verliest. Elke code kan slechts één keer worden gebruikt.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "U kunt te allen tijde nieuwe back-up codes genereren. Dit zal alle bestaande codes ongeldig maken.",
"Confirm password": "Bevestig wachtwoord",
"Generate new backup codes": "Genereer nieuwe back-up codes",
"Save your new backup codes": "Sla uw nieuwe back-up codes op",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Zorg ervoor dat u deze codes op een veilige plek opslaat. Uw oude back-up codes zijn niet langer geldig.",
"Your new backup codes": "Uw nieuwe back-up codes",
"I've saved my backup codes": "Ik heb mijn back-up codes opgeslagen",
"Failed to setup MFA": "MFA instellen mislukt",
"Setup & Verify": "Instellen & Verifiëren",
"Add to authenticator": "Toevoegen aan de authenticator",
"1. Scan this QR code with your authenticator app": "1. Scan deze QR-code met uw authenticator-app",
"Can't scan the code?": "Kan de code niet scannen?",
"Enter this code manually in your authenticator app:": "Voer deze code handmatig in uw authenticator-app in:",
"2. Enter the 6-digit code from your authenticator": "2. Voer de 6-cijferige code van uw authenticator in",
"Verify and enable": "Verifiëren en inschakelen",
"Failed to generate QR code. Please try again.": "Het genereren van de QR-code is mislukt. Probeer het opnieuw.",
"Backup": "Back-up",
"Save codes": "Codes opslaan",
"Save your backup codes": "Sla uw back-up codes op",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Deze codes kunnen worden gebruikt om toegang te krijgen tot uw account als u de toegang tot uw authenticator-app verliest. Elke code kan slechts één keer worden gebruikt.",
"Print": "Afdrukken",
"Two-factor authentication has been set up. Please log in again.": "Twee-factor authenticatie is ingesteld. Log alstublieft opnieuw in.",
"Two-Factor authentication required": "Twee-factor authenticatie vereist",
"Your workspace requires two-factor authentication for all users": "Uw werkruimte vereist twee-factor authenticatie voor alle gebruikers",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Om toegang te blijven krijgen tot uw werkruimte, moet u twee-factor authenticatie instellen. Dit voegt een extra beveiligingslaag toe aan uw account.",
"Set up two-factor authentication": "Stel twee-factor authenticatie in",
"Cancel and logout": "Annuleren en uitloggen",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Uw werkruimte vereist twee-factor authenticatie. Stel het in om door te gaan.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Dit voegt een extra beveiligingslaag toe aan uw account door een verificatiecode van uw authenticator-app te vereisen.",
"Password is required": "Wachtwoord is vereist",
"Password must be at least 8 characters": "Wachtwoord moet minimaal 8 tekens zijn",
"Please enter a 6-digit code": "Voer alstublieft een 6-cijferige code in",
"Code must be exactly 6 digits": "Code moet exact 6 cijfers zijn",
"Enter the 6-digit code found in your authenticator app": "Voer de 6-cijferige code in die in uw authenticator-app staat",
"Need help authenticating?": "Hulp nodig bij het authenticeren?",
"MFA QR Code": "MFA QR-code",
"Account created successfully. Please log in to set up two-factor authentication.": "Account succesvol aangemaakt. Log alstublieft in om twee-factor authenticatie in te stellen.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Wachtwoord reset succesvol. Log in met uw nieuwe wachtwoord en voltooi twee-factor authenticatie.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Wachtwoord reset succesvol. Log in met uw nieuwe wachtwoord om twee-factor authenticatie in te stellen.",
"Password reset was successful. Please log in with your new password.": "De wachtwoord reset was succesvol. Log in met uw nieuwe wachtwoord.",
"Two-factor authentication": "Twee-factor authenticatie",
"Use authenticator app instead": "Gebruik in plaats daarvan de authenticator-app",
"Verify backup code": "Back-up code verifiëren",
"Use backup code": "Gebruik back-up code",
"Enter one of your backup codes": "Voer een van uw back-up codes in",
"Backup code": "Back-up code",
"Enter one of your backup codes. Each backup code can only be used once.": "Voer een van uw back-up codes in. Elke back-up code kan slechts één keer worden gebruikt.",
"Verify": "Verifiëren",
"Trash": "Prullenbak",
"Pages in trash will be permanently deleted after 30 days.": "Pagina's in de prullenbak worden na 30 dagen permanent verwijderd.",
"Deleted": "Verwijderd",
"No pages in trash": "Geen pagina's in de prullenbak",
"Permanently delete page?": "Pagina permanent verwijderen?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Weet u zeker dat u '{{title}}' permanent wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
"Restore '{{title}}' and its sub-pages?": "'{{title}}' en zijn subpagina's herstellen?",
"Move to trash": "Naar de prullenbak verplaatsen",
"Move this page to trash?": "Deze pagina naar de prullenbak verplaatsen?",
"Restore page": "Pagina herstellen",
"Page moved to trash": "Pagina verplaatst naar de prullenbak",
"Page restored successfully": "Pagina succesvol hersteld",
"Deleted by": "Verwijderd door",
"Deleted at": "Verwijderd op",
"Preview": "Voorbeeld",
"Subpages": "Subpagina's",
"Failed to load subpages": "Laden van subpagina's mislukt",
"No subpages": "Geen subpagina's",
"Subpages (Child pages)": "Subpagina's (Kindpagina's)",
"List all subpages of the current page": "Lijst van alle subpagina's van de huidige pagina",
"Attachments": "Bijlagen",
"All spaces": "Alle ruimtes",
"Unknown": "Onbekend",
"Find a space": "Vind een ruimte",
"Search in all your spaces": "Zoek in al je ruimtes",
"Type": "Type",
"Enterprise": "Onderneming",
"Download attachment": "Bijlage downloaden",
"Allowed email domains": "Toegestane e-maildomeinen",
"Only users with email addresses from these domains can signup via SSO.": "Alleen gebruikers met e-mailadressen van deze domeinen kunnen zich aanmelden via SSO.",
"Enter valid domain names separated by comma or space": "Voer geldige domeinnamen in, gescheiden door komma of spatie",
"Enforce two-factor authentication": "Handhaaf tweefactorauthenticatie",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Na handhaving moeten alle leden tweefactorauthenticatie inschakelen om toegang te krijgen tot de werkomgeving.",
"Toggle MFA enforcement": "Schakel MFA-handhaving in of uit",
"Display name": "Weergavenaam",
"Allow signup": "Aanmelden toestaan",
"Enabled": "Ingeschakeld",
"Advanced Settings": "Geavanceerde instellingen",
"Enable TLS/SSL": "TLS/SSL inschakelen",
"Use secure connection to LDAP server": "Gebruik een beveiligde verbinding met de LDAP-server",
"Group sync": "Groepssynchronisatie",
"No SSO providers found.": "Geen SSO-providers gevonden.",
"Delete SSO provider": "Verwijder SSO-provider",
"Are you sure you want to delete this SSO provider?": "Weet u zeker dat u deze SSO-provider wilt verwijderen?",
"Action": "Actie",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} configuratie"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "ex.: Espaço para a equipe de produto",
"e.g Space for sales team to collaborate": "ex.: Espaço para a equipe de vendas colaborar",
"Edit": "Editar",
"Read": "Ler",
"Edit group": "Editar grupo",
"Email": "Email",
"Enter a strong password": "Insira uma senha forte",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Comentário excluído com sucesso",
"Failed to delete comment": "Falha ao excluir comentário",
"Comment resolved successfully": "Comentário resolvido com sucesso",
"Comment re-opened successfully": "Comentário reaberto com sucesso",
"Comment unresolved successfully": "Comentário não resolvido com sucesso",
"Failed to resolve comment": "Falha ao resolver comentário",
"Resolve comment": "Resolver comentário",
"Unresolve comment": "Não resolver comentário",
"Resolve Comment Thread": "Resolver Fio de Comentários",
"Unresolve Comment Thread": "Não resolver Fio de Comentários",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Tem certeza de que deseja resolver este fio de comentários? Isso o marcará como concluído.",
"Are you sure you want to unresolve this comment thread?": "Tem certeza de que deseja não resolver este fio de comentários?",
"Resolved": "Resolvido",
"No active comments.": "Sem comentários ativos.",
"No resolved comments.": "Sem comentários resolvidos.",
"Revoke invitation": "Cancelar o convite",
"Revoke": "Anular",
"Don't": "Não",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Qualquer um com este link pode participar deste espaço de trabalho.",
"Invite link": "Link do convite",
"Copy": "Copiar",
"Copy to space": "Copiar para o espaço",
"Copied": "Copiado",
"Duplicate": "Duplicar",
"Select a user": "Selecione um usuário",
"Select a group": "Selecione um grupo",
"Export all pages and attachments in this space.": "Exportar todas as páginas e anexos deste espaço.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
"New update": "Nova atualização",
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
"Default page edit mode": "Modo de edição de página padrão",
"Choose your preferred page edit mode. Avoid accidental edits.": "Escolha o modo de edição de página preferido. Evite edições acidentais.",
"Reading": "Leitura",
"Delete member": "Excluir membro",
"Member deleted successfully": "Membro removido com sucesso",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
@ -384,7 +401,131 @@
"Share deleted successfully": "Compartilhamento excluído com sucesso",
"Share not found": "Compartilhamento não encontrado",
"Failed to share page": "Falha ao compartilhar página",
"Copy page": "Copy page",
"Copy page to a different space.": "Copy page to a different space.",
"Page copied successfully": "Page copied successfully"
"Copy page": "Copiar página",
"Copy page to a different space.": "Copiar página para um espaço diferente.",
"Page copied successfully": "Página copiada com sucesso",
"Page duplicated successfully": "Página duplicada com sucesso",
"Find": "Encontrar",
"Not found": "Não encontrado",
"Previous Match (Shift+Enter)": "Correspondência anterior (Shift+Enter)",
"Next match (Enter)": "Próxima correspondência (Enter)",
"Match case (Alt+C)": "Diferenciar maiúsculas de minúsculas (Alt+C)",
"Replace": "Substituir",
"Close (Escape)": "Fechar (Escape)",
"Replace (Enter)": "Substituir (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Substituir tudo (Ctrl+Alt+Enter)",
"Replace all": "Substituir tudo",
"View all spaces": "Ver todos os espaços",
"Error": "Erro",
"Failed to disable MFA": "Falha ao desativar a MFA",
"Disable two-factor authentication": "Desativar autenticação de dois fatores",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Desativar a autenticação de dois fatores tornará sua conta menos segura. Você só precisará de sua senha para entrar.",
"Please enter your password to disable two-factor authentication:": "Por favor, insira sua senha para desativar a autenticação de dois fatores:",
"Two-factor authentication has been enabled": "Autenticação de dois fatores foi ativada",
"Two-factor authentication has been disabled": "Autenticação de dois fatores foi desativada",
"2-step verification": "Verificação em duas etapas",
"Protect your account with an additional verification layer when signing in.": "Proteja sua conta com uma camada adicional de verificação ao entrar.",
"Two-factor authentication is active on your account.": "Autenticação de dois fatores está ativa na sua conta.",
"Add 2FA method": "Adicionar método de 2FA",
"Backup codes": "Códigos de backup",
"Disable": "Desativar",
"Invalid verification code": "Código de verificação inválido",
"New backup codes have been generated": "Novos códigos de backup foram gerados",
"Failed to regenerate backup codes": "Falha ao regenerar códigos de backup",
"About backup codes": "Sobre códigos de backup",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Códigos de backup podem ser usados para acessar sua conta se perder acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Você pode regenerar novos códigos de backup a qualquer momento. Isso invalidará todos os códigos existentes.",
"Confirm password": "Confirmar senha",
"Generate new backup codes": "Gerar novos códigos de backup",
"Save your new backup codes": "Salvar seus novos códigos de backup",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Certifique-se de salvar esses códigos em um local seguro. Seus códigos de backup antigos não são mais válidos.",
"Your new backup codes": "Seus novos códigos de backup",
"I've saved my backup codes": "Eu salvei meus códigos de backup",
"Failed to setup MFA": "Falha ao configurar a MFA",
"Setup & Verify": "Configurar & Verificar",
"Add to authenticator": "Adicionar ao autenticador",
"1. Scan this QR code with your authenticator app": "1. Escaneie este código QR com seu aplicativo autenticador",
"Can't scan the code?": "Não consegue escanear o código?",
"Enter this code manually in your authenticator app:": "Digite este código manualmente em seu aplicativo autenticador:",
"2. Enter the 6-digit code from your authenticator": "2. Digite o código de 6 dígitos do seu autenticador",
"Verify and enable": "Verificar e ativar",
"Failed to generate QR code. Please try again.": "Falha ao gerar código QR. Por favor, tente novamente.",
"Backup": "Backup",
"Save codes": "Salvar códigos",
"Save your backup codes": "Salvar seus códigos de backup",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Esses códigos podem ser usados para acessar sua conta se você perder o acesso ao aplicativo autenticador. Cada código só pode ser usado uma vez.",
"Print": "Imprimir",
"Two-factor authentication has been set up. Please log in again.": "A autenticação de dois fatores foi configurada. Por favor, faça login novamente.",
"Two-Factor authentication required": "Autenticação de dois fatores necessária",
"Your workspace requires two-factor authentication for all users": "Seu espaço de trabalho requer autenticação de dois fatores para todos os usuários",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Para continuar acessando seu espaço de trabalho, você deve configurar a autenticação de dois fatores. Isso adiciona uma camada extra de segurança à sua conta.",
"Set up two-factor authentication": "Configurar autenticação de dois fatores",
"Cancel and logout": "Cancelar e sair",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Seu espaço de trabalho requer autenticação de dois fatores. Por favor, configure para continuar.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Isso adiciona uma camada extra de segurança à sua conta, exigindo um código de verificação de seu aplicativo autenticador.",
"Password is required": "Senha é necessária",
"Password must be at least 8 characters": "A senha deve ter pelo menos 8 caracteres",
"Please enter a 6-digit code": "Por favor, insira um código de 6 dígitos",
"Code must be exactly 6 digits": "O código deve ter exatamente 6 dígitos",
"Enter the 6-digit code found in your authenticator app": "Insira o código de 6 dígitos encontrado em seu aplicativo autenticador",
"Need help authenticating?": "Precisa de ajuda para autenticar?",
"MFA QR Code": "Código QR de MFA",
"Account created successfully. Please log in to set up two-factor authentication.": "Conta criada com sucesso. Por favor, faça login para configurar a autenticação de dois fatores.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Redefinição de senha bem-sucedida. Por favor, faça login com sua nova senha e complete a autenticação de dois fatores.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Redefinição de senha bem-sucedida. Por favor, faça login com sua nova senha para configurar a autenticação de dois fatores.",
"Password reset was successful. Please log in with your new password.": "Redefinição de senha foi bem-sucedida. Por favor, faça login com sua nova senha.",
"Two-factor authentication": "Autenticação de dois fatores",
"Use authenticator app instead": "Use o aplicativo autenticador em vez disso",
"Verify backup code": "Verificar código de backup",
"Use backup code": "Usar código de backup",
"Enter one of your backup codes": "Digite um de seus códigos de backup",
"Backup code": "Código de backup",
"Enter one of your backup codes. Each backup code can only be used once.": "Digite um de seus códigos de backup. Cada código de backup só pode ser usado uma vez.",
"Verify": "Verificar",
"Trash": "Lixeira",
"Pages in trash will be permanently deleted after 30 days.": "Páginas na lixeira serão excluídas permanentemente após 30 dias.",
"Deleted": "Excluído",
"No pages in trash": "Sem páginas na lixeira",
"Permanently delete page?": "Excluir página permanentemente?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Tem certeza de que deseja excluir permanentemente '{{title}}'? Esta ação não pode ser desfeita.",
"Restore '{{title}}' and its sub-pages?": "Restaurar '{{title}}' e suas subpáginas?",
"Move to trash": "Mover para a lixeira",
"Move this page to trash?": "Mover esta página para a lixeira?",
"Restore page": "Restaurar página",
"Page moved to trash": "Página movida para a lixeira",
"Page restored successfully": "Página restaurada com sucesso",
"Deleted by": "Excluído por",
"Deleted at": "Excluído em",
"Preview": "Visualização",
"Subpages": "Subpáginas",
"Failed to load subpages": "Falha ao carregar subpáginas",
"No subpages": "Sem subpáginas",
"Subpages (Child pages)": "Subpáginas (Páginas filhas)",
"List all subpages of the current page": "Listar todas as subpáginas da página atual",
"Attachments": "Anexos",
"All spaces": "Todos os espaços",
"Unknown": "Desconhecido",
"Find a space": "Encontrar um espaço",
"Search in all your spaces": "Pesquisar em todos os seus espaços",
"Type": "Tipo",
"Enterprise": "Empresa",
"Download attachment": "Baixar anexo",
"Allowed email domains": "Domínios de email permitidos",
"Only users with email addresses from these domains can signup via SSO.": "Apenas usuários com endereços de email desses domínios podem se inscrever via SSO.",
"Enter valid domain names separated by comma or space": "Insira nomes de domínio válidos separados por vírgula ou espaço",
"Enforce two-factor authentication": "Impor autenticação de dois fatores",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Uma vez imposto, todos os membros devem habilitar a autenticação de dois fatores para acessar o espaço de trabalho.",
"Toggle MFA enforcement": "Alternar imposição de MFA",
"Display name": "Nome de exibição",
"Allow signup": "Permitir inscrição",
"Enabled": "Habilitado",
"Advanced Settings": "Configurações Avançadas",
"Enable TLS/SSL": "Habilitar TLS/SSL",
"Use secure connection to LDAP server": "Usar conexão segura com o servidor LDAP",
"Group sync": "Sincronização de grupo",
"No SSO providers found.": "Nenhum provedor de SSO encontrado.",
"Delete SSO provider": "Excluir provedor de SSO",
"Are you sure you want to delete this SSO provider?": "Tem certeza de que deseja excluir este provedor de SSO?",
"Action": "Ação",
"{{ssoProviderType}} configuration": "Configuração de {{ssoProviderType}}"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "например, Пространство для продуктовой команды",
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
"Edit": "Редактировать",
"Read": "Читать",
"Edit group": "Редактировать группу",
"Email": "Электронная почта",
"Enter a strong password": "Введите надёжный пароль",
@ -61,7 +62,7 @@
"Enter your current password": "Введите ваш текущий пароль",
"enter your full name": "введите ваше полное имя",
"Enter your new password": "Введите ваш новый пароль",
"Enter your new preferred email": "Введите ваш новый предпочитаемый адрес электронной почты",
"Enter your new preferred email": "Введите ваш новый предпочтительный адрес электронной почты",
"Enter your password": "Введите ваш пароль",
"Error fetching page data.": "Ошибка при загрузке данных страницы.",
"Error loading page history.": "Ошибка при загрузке истории страницы.",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Комментарий успешно удалён",
"Failed to delete comment": "Не удалось удалить комментарий",
"Comment resolved successfully": "Комментарий успешно разрешён",
"Comment re-opened successfully": "Комментарий успешно открыт заново",
"Comment unresolved successfully": "Комментарий успешно размечен как нерешённый",
"Failed to resolve comment": "Не удалось разрешить комментарий",
"Resolve comment": "Разрешить комментарий",
"Unresolve comment": "Отметить комментарий как нерешённый",
"Resolve Comment Thread": "Закрыть цепочку комментариев",
"Unresolve Comment Thread": "Отметить цепочку комментариев как нерешённую",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Вы уверены, что хотите закрыть эту цепочку комментариев? Это пометит её как завершённую.",
"Are you sure you want to unresolve this comment thread?": "Вы уверены, что хотите отметить эту цепочку комментариев как нерешённую?",
"Resolved": "Решено",
"No active comments.": "Нет активных комментариев.",
"No resolved comments.": "Нет решённых комментариев.",
"Revoke invitation": "Отозвать приглашение",
"Revoke": "Отозвать",
"Don't": "Нет",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
"Invite link": "Ссылка для приглашения",
"Copy": "Копировать",
"Copy to space": "Копировать в пространство",
"Copied": "Скопировано",
"Duplicate": "Дублировать",
"Select a user": "Выберите пользователя",
"Select a group": "Выберите группу",
"Export all pages and attachments in this space.": "Экспортировать все страницы и вложения в этом пространстве.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
"New update": "Новое обновление",
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
"Default page edit mode": "Режим редактирования страницы по умолчанию",
"Choose your preferred page edit mode. Avoid accidental edits.": "Выберите предпочитаемый режим редактирования страницы. Избегайте случайных изменений.",
"Reading": "Чтение",
"Delete member": "Удалить участника",
"Member deleted successfully": "Участник успешно удален",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
@ -386,5 +403,129 @@
"Failed to share page": "Не удалось поделиться страницей",
"Copy page": "Копировать страницу",
"Copy page to a different space.": "Копировать страницу в другое пространство.",
"Page copied successfully": "Страница успешно скопирована"
"Page copied successfully": "Страница успешно скопирована",
"Page duplicated successfully": "Страница успешно дублирована",
"Find": "Найти",
"Not found": "Не найдено",
"Previous Match (Shift+Enter)": "Предыдущее совпадение (Shift+Enter)",
"Next match (Enter)": "Следующее совпадение (Enter)",
"Match case (Alt+C)": "Учитывать регистр (Alt+C)",
"Replace": "Заменить",
"Close (Escape)": "Закрыть (Escape)",
"Replace (Enter)": "Заменить (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Заменить все (Ctrl+Alt+Enter)",
"Replace all": "Заменить все",
"View all spaces": "Просмотреть все пространства",
"Error": "Ошибка",
"Failed to disable MFA": "Не удалось отключить двухфакторную аутентификацию",
"Disable two-factor authentication": "Отключить двухфакторную аутентификацию",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Отключение двухфакторной аутентификации сделает вашу учетную запись менее безопасной. Для входа потребуется только пароль.",
"Please enter your password to disable two-factor authentication:": "Пожалуйста, введите ваш пароль, чтобы отключить двухфакторную аутентификацию:",
"Two-factor authentication has been enabled": "Двухфакторная аутентификация включена",
"Two-factor authentication has been disabled": "Двухфакторная аутентификация отключена",
"2-step verification": "Двухэтапная проверка",
"Protect your account with an additional verification layer when signing in.": "Защитите свою учетную запись дополнительным уровнем проверки при входе.",
"Two-factor authentication is active on your account.": "Двухфакторная аутентификация активна на вашей учетной записи.",
"Add 2FA method": "Добавить метод 2FA",
"Backup codes": "Резервные коды",
"Disable": "Отключить",
"Invalid verification code": "Неверный код проверки",
"New backup codes have been generated": "Созданы новые резервные коды",
"Failed to regenerate backup codes": "Не удалось создать новые резервные коды",
"About backup codes": "О резервных кодах",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Резервные коды можно использовать для доступа к вашей учетной записи, если вы потеряли доступ к приложению-аутентификатору. Каждый код можно использовать только один раз.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Вы можете создать новые резервные коды в любое время. Это аннулирует все существующие коды.",
"Confirm password": "Подтвердите пароль",
"Generate new backup codes": "Создать новые резервные коды",
"Save your new backup codes": "Сохраните ваши новые резервные коды",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Убедитесь, что сохранили эти коды в безопасном месте. Ваши старые резервные коды больше недействительны.",
"Your new backup codes": "Ваши новые резервные коды",
"I've saved my backup codes": "Я сохранил(а) свои резервные коды",
"Failed to setup MFA": "Не удалось настроить многофакторную аутентификацию",
"Setup & Verify": "Настроить и проверить",
"Add to authenticator": "Добавить в аутентификатор",
"1. Scan this QR code with your authenticator app": "1. Отсканируйте этот QR-код с помощью вашего приложения-аутентификатора",
"Can't scan the code?": "Не удается сканировать код?",
"Enter this code manually in your authenticator app:": "Введите этот код вручную в приложении-аутентификаторе:",
"2. Enter the 6-digit code from your authenticator": "2. Введите 6-значный код из вашего аутентификатора",
"Verify and enable": "Проверить и включить",
"Failed to generate QR code. Please try again.": "Не удалось создать QR-код. Пожалуйста, попробуйте снова.",
"Backup": "Резервное копирование",
"Save codes": "Сохранить коды",
"Save your backup codes": "Сохраните ваши резервные коды",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Эти коды можно использовать для доступа к вашей учетной записи, если вы потеряли доступ к приложению-аутентификатору. Каждый код можно использовать только один раз.",
"Print": "Печать",
"Two-factor authentication has been set up. Please log in again.": "Двухфакторная аутентификация настроена. Пожалуйста, войдите снова.",
"Two-Factor authentication required": "Требуется двухфакторная аутентификация",
"Your workspace requires two-factor authentication for all users": "Ваше рабочее пространство требует двухфакторной аутентификации для всех пользователей",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Чтобы продолжать доступ к вашему рабочему пространству, вы должны настроить двухфакторную аутентификацию. Это добавляет дополнительный уровень безопасности к вашей учетной записи.",
"Set up two-factor authentication": "Настройте двухфакторную аутентификацию",
"Cancel and logout": "Отменить и выйти",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Ваше рабочее пространство требует двухфакторной аутентификации. Пожалуйста, настройте её, чтобы продолжить.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Это добавляет дополнительный уровень безопасности к вашей учетной записи, требуя код проверки из вашего приложения-аутентификатора.",
"Password is required": "Требуется пароль",
"Password must be at least 8 characters": "Пароль должен содержать как минимум 8 символов",
"Please enter a 6-digit code": "Пожалуйста, введите 6-значный код",
"Code must be exactly 6 digits": "Код должен содержать ровно 6 цифр",
"Enter the 6-digit code found in your authenticator app": "Введите 6-значный код из вашего приложения-аутентификатора",
"Need help authenticating?": "Нужна помощь с аутентификацией?",
"MFA QR Code": "QR-код двухфакторной аутентификации",
"Account created successfully. Please log in to set up two-factor authentication.": "Учетная запись успешно создана. Пожалуйста, войдите, чтобы настроить двухфакторную аутентификацию.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Сброс пароля выполнен успешно. Пожалуйста, войдите с вашим новым паролем и завершите настройку двухфакторной аутентификации.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Сброс пароля выполнен успешно. Пожалуйста, войдите с вашим новым паролем, чтобы настроить двухфакторную аутентификацию.",
"Password reset was successful. Please log in with your new password.": "Сброс пароля выполнен успешно. Пожалуйста, войдите с вашим новым паролем.",
"Two-factor authentication": "Двухфакторная аутентификация",
"Use authenticator app instead": "Используйте приложение-аутентификатор вместо этого",
"Verify backup code": "Проверка резервного кода",
"Use backup code": "Использовать резервный код",
"Enter one of your backup codes": "Введите один из ваших резервных кодов",
"Backup code": "Резервный код",
"Enter one of your backup codes. Each backup code can only be used once.": "Введите один из ваших резервных кодов. Каждый резервный код можно использовать только один раз.",
"Verify": "Проверить",
"Trash": "Корзина",
"Pages in trash will be permanently deleted after 30 days.": "Страницы в корзине будут окончательно удалены через 30 дней.",
"Deleted": "Удалено",
"No pages in trash": "В корзине нет страниц",
"Permanently delete page?": "Удалить страницу окончательно?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
"Move to trash": "Переместить в корзину",
"Move this page to trash?": "Переместить эту страницу в корзину?",
"Restore page": "Восстановить страницу",
"Page moved to trash": "Страница перемещена в корзину",
"Page restored successfully": "Страница успешно восстановлена",
"Deleted by": "Удалено пользователем",
"Deleted at": "Удалено в",
"Preview": "Предпросмотр",
"Subpages": "Подстраницы",
"Failed to load subpages": "Не удалось загрузить подстраницы",
"No subpages": "Нет подстраниц",
"Subpages (Child pages)": "Подстраницы (вложенные страницы)",
"List all subpages of the current page": "Показать все подстраницы текущей страницы",
"Attachments": "Вложения",
"All spaces": "Все пространства",
"Unknown": "Неизвестно",
"Find a space": "Найти пространство",
"Search in all your spaces": "Поиск во всех ваших пространствах",
"Type": "Тип",
"Enterprise": "Предприятие",
"Download attachment": "Скачать вложение",
"Allowed email domains": "Разрешенные домены электронной почты",
"Only users with email addresses from these domains can signup via SSO.": "Только пользователи с электронными адресами из этих доменов могут зарегистрироваться через SSO.",
"Enter valid domain names separated by comma or space": "Введите допустимые доменные имена, разделённые запятыми или пробелами",
"Enforce two-factor authentication": "Обязательная двухфакторная аутентификация",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "После введения обязательности все участники должны будут включить двухфакторную аутентификацию для доступа к рабочему пространству.",
"Toggle MFA enforcement": "Переключить обязательность MFA",
"Display name": "Отображаемое имя",
"Allow signup": "Разрешить регистрацию",
"Enabled": "Включено",
"Advanced Settings": "Расширенные настройки",
"Enable TLS/SSL": "Включить TLS/SSL",
"Use secure connection to LDAP server": "Использовать защищённое соединение с сервером LDAP",
"Group sync": "Синхронизация группы",
"No SSO providers found.": "Поставщики SSO не найдены.",
"Delete SSO provider": "Удалить поставщика SSO",
"Are you sure you want to delete this SSO provider?": "Вы уверены, что хотите удалить этого поставщика SSO?",
"Action": "Действие",
"{{ssoProviderType}} configuration": "Настройка {{ssoProviderType}}"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "наприклад, Простір для продуктової команди",
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
"Edit": "Редагувати",
"Read": "Читати",
"Edit group": "Редагувати групу",
"Email": "Електронна пошта",
"Enter a strong password": "Введіть надійний пароль",
@ -213,7 +214,18 @@
"Comment deleted successfully": "Коментар успішно видалено",
"Failed to delete comment": "Не вдалося видалити коментар",
"Comment resolved successfully": "Коментар успішно вирішено",
"Comment re-opened successfully": "Коментар успішно відкрито повторно",
"Comment unresolved successfully": "Коментар успішно розв'язано",
"Failed to resolve comment": "Не вдалося вирішити коментар",
"Resolve comment": "Вирішити коментар",
"Unresolve comment": "Розв'язати коментар",
"Resolve Comment Thread": "Вирішити ланцюжок коментарів",
"Unresolve Comment Thread": "Розв'язати ланцюжок коментарів",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Ви впевнені, що хочете вирішити цей ланцюжок коментарів? Це позначить його як завершений.",
"Are you sure you want to unresolve this comment thread?": "Ви впевнені, що хочете розв'язати цей ланцюжок коментарів?",
"Resolved": "Вирішено",
"No active comments.": "Немає активних коментарів.",
"No resolved comments.": "Немає вирішених коментарів.",
"Revoke invitation": "Відкликати запрошення",
"Revoke": "Відкликати",
"Don't": "Ні",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
"Invite link": "Посилання для запрошення",
"Copy": "Копіювати",
"Copy to space": "Скопіювати в простір",
"Copied": "Скопійовано",
"Duplicate": "Дублювати",
"Select a user": "Оберіть користувача",
"Select a group": "Оберіть групу",
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
"New update": "Нове оновлення",
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
"Default page edit mode": "Режим редагування сторінки за замовчуванням",
"Choose your preferred page edit mode. Avoid accidental edits.": "Виберіть бажаний режим редагування сторінки. Уникайте випадкових редагувань.",
"Reading": "Читання",
"Delete member": "Видалити учасника",
"Member deleted successfully": "Учасника успішно видалено",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
@ -386,5 +403,129 @@
"Failed to share page": "Не вдалося поділитися сторінкою",
"Copy page": "Копіювати сторінки",
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
"Page copied successfully": "Сторінку успішно скопійовано"
"Page copied successfully": "Сторінку успішно скопійовано",
"Page duplicated successfully": "Сторінку успішно дубльовано",
"Find": "Знайти",
"Not found": "Не знайдено",
"Previous Match (Shift+Enter)": "Попередній збіг (Shift+Enter)",
"Next match (Enter)": "Наступний збіг (Enter)",
"Match case (Alt+C)": "Враховувати регістр (Alt+C)",
"Replace": "Замінити",
"Close (Escape)": "Закрити (Escape)",
"Replace (Enter)": "Замінити (Enter)",
"Replace all (Ctrl+Alt+Enter)": "Замінити все (Ctrl+Alt+Enter)",
"Replace all": "Замінити все",
"View all spaces": "Переглянути всі простори",
"Error": "Помилка",
"Failed to disable MFA": "Не вдалося вимкнути MFA",
"Disable two-factor authentication": "Вимкнути двоетапну аутентифікацію",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "Вимкнення двоетапної аутентифікації зробить ваш обліковий запис менш захищеним. Для входу потрібен лише пароль.",
"Please enter your password to disable two-factor authentication:": "Будь ласка, введіть свій пароль, щоб вимкнути двоетапну аутентифікацію:",
"Two-factor authentication has been enabled": "Двоетапну аутентифікацію включено",
"Two-factor authentication has been disabled": "Двоетапну аутентифікацію вимкнено",
"2-step verification": "Двоетапна перевірка",
"Protect your account with an additional verification layer when signing in.": "Захистіть свій обліковий запис за допомогою додаткового шару перевірки при вході.",
"Two-factor authentication is active on your account.": "Двоетапну аутентифікацію активовано у вашому обліковому записі.",
"Add 2FA method": "Додати метод 2FA",
"Backup codes": "Резервні коди",
"Disable": "Вимкнути",
"Invalid verification code": "Невірний код перевірки",
"New backup codes have been generated": "Нові резервні коди створено",
"Failed to regenerate backup codes": "Не вдалося повторно створити резервні коди",
"About backup codes": "Про резервні коди",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Резервні коди можуть бути використані для доступу до вашого облікового запису, якщо ви втратите доступ до додатку аутентифікатора. Кожен код можна використовувати лише один раз.",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "Ви можете повторно створити нові резервні коди в будь-який час. Це зробить усі існуючі коди недійсними.",
"Confirm password": "Підтвердити пароль",
"Generate new backup codes": "Створити нові резервні коди",
"Save your new backup codes": "Збережіть нові резервні коди",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "Обов'язково збережіть ці коди у безпечному місці. Ваші старі резервні коди більше не дійсні.",
"Your new backup codes": "Ваші нові резервні коди",
"I've saved my backup codes": "Я зберіг резервні коди",
"Failed to setup MFA": "Не вдалося налаштувати MFA",
"Setup & Verify": "Налаштувати та перевірити",
"Add to authenticator": "Додати до аутентифікатора",
"1. Scan this QR code with your authenticator app": "1. Скануйте цей QR-код за допомогою додатку аутентифікатора",
"Can't scan the code?": "Не можете відсканувати код?",
"Enter this code manually in your authenticator app:": "Введіть цей код вручну у додатку аутентифікатора:",
"2. Enter the 6-digit code from your authenticator": "2. Введіть 6-значний код із аутентифікатора",
"Verify and enable": "Перевірити та увімкнути",
"Failed to generate QR code. Please try again.": "Не вдалося створити QR-код. Будь ласка, спробуйте ще раз.",
"Backup": "Резервне копіювання",
"Save codes": "Зберегти коди",
"Save your backup codes": "Зберегти резервні коди",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "Ці коди можуть бути використані для доступу до вашого облікового запису, якщо ви втратите доступ до додатку аутентифікатора. Кожен код можна використовувати лише один раз.",
"Print": "Друкувати",
"Two-factor authentication has been set up. Please log in again.": "Двоетапну аутентифікацію налаштовано. Будь ласка, увійдіть знову.",
"Two-Factor authentication required": "Потрібна двоетапна аутентифікація",
"Your workspace requires two-factor authentication for all users": "Ваш робочий простір вимагає двоетапної аутентифікації для всіх користувачів",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "Щоб продовжити доступ до робочого простору, вам потрібно налаштувати двоетапну аутентифікацію. Це додає додатковий шар захисту до вашого облікового запису.",
"Set up two-factor authentication": "Налаштувати двоетапну аутентифікацію",
"Cancel and logout": "Скасувати та вийти",
"Your workspace requires two-factor authentication. Please set it up to continue.": "Ваш робочий простір вимагає двоетапної аутентифікації. Будь ласка, налаштуйте це щоб продовжити.",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "Це додає додатковий шар захисту до вашого облікового запису, вимагаючи код підтвердження з вашого додатку аутентифікатора.",
"Password is required": "Вимагається пароль",
"Password must be at least 8 characters": "Пароль повинен містити щонайменше 8 символів",
"Please enter a 6-digit code": "Будь ласка, введіть 6-значний код",
"Code must be exactly 6 digits": "Код повинен мати точно 6 цифр",
"Enter the 6-digit code found in your authenticator app": "Введіть 6-значний код з вашого додатку аутентифікатора",
"Need help authenticating?": "Потрібна допомога з аутентифікацією?",
"MFA QR Code": "MFA QR-код",
"Account created successfully. Please log in to set up two-factor authentication.": "Обліковий запис успішно створено. Будь ласка, увійдіть, щоб налаштувати двоетапну аутентифікацію.",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "Скидання паролю успішне. Будь ласка, увійдіть за допомогою нового паролю та завершіть двоетапну аутентифікацію.",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "Скидання паролю успішне. Будь ласка, увійдіть за допомогою нового паролю, щоб налаштувати двоетапну аутентифікацію.",
"Password reset was successful. Please log in with your new password.": "Скидання паролю успішне. Будь ласка, увійдіть за допомогою нового паролю.",
"Two-factor authentication": "Двоетапна аутентифікація",
"Use authenticator app instead": "Використовуйте додаток аутентифікатора замість цього",
"Verify backup code": "Перевірити резервний код",
"Use backup code": "Використовуйте резервний код",
"Enter one of your backup codes": "Введіть один з ваших резервних кодів",
"Backup code": "Резервний код",
"Enter one of your backup codes. Each backup code can only be used once.": "Введіть один з ваших резервних кодів. Кожен резервний код можна використовувати лише один раз.",
"Verify": "Перевірити",
"Trash": "Кошик",
"Pages in trash will be permanently deleted after 30 days.": "Сторінки у кошику будуть остаточно видалені через 30 днів.",
"Deleted": "Видалено",
"No pages in trash": "Немає сторінок у кошику",
"Permanently delete page?": "Остаточно видалити сторінку?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Ви впевнені, що хочете остаточно видалити '{{title}}'? Цю дію не можна скасувати.",
"Restore '{{title}}' and its sub-pages?": "Відновити '{{title}}' та її підсторінки?",
"Move to trash": "Перемістити до кошика",
"Move this page to trash?": "Перемістити цю сторінку до кошика?",
"Restore page": "Відновити сторінку",
"Page moved to trash": "Сторінка переміщена до кошика",
"Page restored successfully": "Сторінку успішно відновлено",
"Deleted by": "Видалено",
"Deleted at": "Видалено о",
"Preview": "Попередній перегляд",
"Subpages": "Підсторінки",
"Failed to load subpages": "Не вдалося завантажити підсторінки",
"No subpages": "Немає підсторінок",
"Subpages (Child pages)": "Підсторінки (дочірні сторінки)",
"List all subpages of the current page": "Перелік всіх підсторінок поточної сторінки",
"Attachments": "Вкладення",
"All spaces": "Усі простори",
"Unknown": "Невідомо",
"Find a space": "Знайти простір",
"Search in all your spaces": "Шукати у всіх ваших просторах",
"Type": "Тип",
"Enterprise": "Підприємство",
"Download attachment": "Завантажити вкладення",
"Allowed email domains": "Дозволені домени електронної пошти",
"Only users with email addresses from these domains can signup via SSO.": "Лише користувачі з адресами електронної пошти з цих доменів можуть реєструватися через SSO.",
"Enter valid domain names separated by comma or space": "Введіть дійсні доменні імена, розділені комою або пробілом",
"Enforce two-factor authentication": "Вимагати двофакторну автентифікацію",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "Після увімкнення всі учасники повинні ввімкнути двофакторну автентифікацію для доступу до робочого простору.",
"Toggle MFA enforcement": "Перемикання вимоги MFA",
"Display name": "Відображуване ім'я",
"Allow signup": "Дозволити реєстрацію",
"Enabled": "Увімкнено",
"Advanced Settings": "Розширені налаштування",
"Enable TLS/SSL": "Увімкнути TLS/SSL",
"Use secure connection to LDAP server": "Використовувати захищене з'єднання з сервером LDAP",
"Group sync": "Синхронізація групи",
"No SSO providers found.": "Постачальників SSO не знайдено.",
"Delete SSO provider": "Видалити постачальника SSO",
"Are you sure you want to delete this SSO provider?": "Ви впевнені, що хочете видалити цього постачальника SSO?",
"Action": "Дія",
"{{ssoProviderType}} configuration": "Конфігурація {{ssoProviderType}}"
}

View File

@ -53,6 +53,7 @@
"e.g Space for product team": "例如:产品团队的空间",
"e.g Space for sales team to collaborate": "例如:销售团队协作的空间",
"Edit": "编辑",
"Read": "阅读",
"Edit group": "编辑群组",
"Email": "电子邮箱",
"Enter a strong password": "输入一个强密码",
@ -213,7 +214,18 @@
"Comment deleted successfully": "成功删除评论",
"Failed to delete comment": "删除评论失败",
"Comment resolved successfully": "成功标记评论为解决",
"Comment re-opened successfully": "成功重新打开评论",
"Comment unresolved successfully": "成功标记评论为未解决",
"Failed to resolve comment": "标记评论为解决失败",
"Resolve comment": "解决评论",
"Unresolve comment": "取消解决评论",
"Resolve Comment Thread": "解决评论线程",
"Unresolve Comment Thread": "取消解决评论线程",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "确定要解决此评论线程吗?这将标记为已完成。",
"Are you sure you want to unresolve this comment thread?": "确定要取消解决此评论线程吗?",
"Resolved": "已解决",
"No active comments.": "没有活跃的评论。",
"No resolved comments.": "没有已解决的评论。",
"Revoke invitation": "撤回邀请",
"Revoke": "撤销",
"Don't": "不要",
@ -222,7 +234,9 @@
"Anyone with this link can join this workspace.": "任何拥有此连接的人都可以加入此工作区",
"Invite link": "邀请链接",
"Copy": "复制",
"Copy to space": "复制到空间",
"Copied": "已复制",
"Duplicate": "重复",
"Select a user": "选择一个用户",
"Select a group": "选择一个组",
"Export all pages and attachments in this space.": "导出当前空间的所有页面和附件",
@ -354,6 +368,9 @@
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
"New update": "新更新",
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
"Default page edit mode": "默认页面编辑模式",
"Choose your preferred page edit mode. Avoid accidental edits.": "选择您偏好的页面编辑模式。避免意外编辑。",
"Reading": "阅读",
"Delete member": "删除成员",
"Member deleted successfully": "成员删除成功",
"Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。",
@ -386,5 +403,129 @@
"Failed to share page": "页面分享失败",
"Copy page": "复制页面",
"Copy page to a different space.": "将页面复制到不同的空间。",
"Page copied successfully": "页面复制成功"
"Page copied successfully": "页面复制成功",
"Page duplicated successfully": "页面复制成功",
"Find": "查找",
"Not found": "未找到",
"Previous Match (Shift+Enter)": "上一个匹配 (Shift+Enter)",
"Next match (Enter)": "下一个匹配 (Enter)",
"Match case (Alt+C)": "区分大小写 (Alt+C)",
"Replace": "替换",
"Close (Escape)": "关闭 (Escape)",
"Replace (Enter)": "替换 (Enter)",
"Replace all (Ctrl+Alt+Enter)": "全部替换 (Ctrl+Alt+Enter)",
"Replace all": "全部替换",
"View all spaces": "查看所有空间",
"Error": "错误",
"Failed to disable MFA": "停用 MFA 失败",
"Disable two-factor authentication": "停用双因素认证",
"Disabling two-factor authentication will make your account less secure. You'll only need your password to sign in.": "停用双因素认证会降低账户安全性。您只需密码即可登录。",
"Please enter your password to disable two-factor authentication:": "请输入您的密码以停用双因素认证:",
"Two-factor authentication has been enabled": "双因素认证已启用",
"Two-factor authentication has been disabled": "双因素认证已停用",
"2-step verification": "两步验证",
"Protect your account with an additional verification layer when signing in.": "通过额外的验证层保护您的账户安全。",
"Two-factor authentication is active on your account.": "您的账户已激活双因素认证。",
"Add 2FA method": "添加 2FA 方法",
"Backup codes": "备份代码",
"Disable": "停用",
"Invalid verification code": "无效的验证码",
"New backup codes have been generated": "已生成新的备份代码",
"Failed to regenerate backup codes": "重新生成备份代码失败",
"About backup codes": "关于备份代码",
"Backup codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "如果您无法访问身份验证器应用,可使用备份代码访问账户。每个代码仅可使用一次。",
"You can regenerate new backup codes at any time. This will invalidate all existing codes.": "您可以随时重新生成新的备份代码。这将使所有现有代码失效。",
"Confirm password": "确认密码",
"Generate new backup codes": "生成新的备份代码",
"Save your new backup codes": "保存您的新备份代码",
"Make sure to save these codes in a secure place. Your old backup codes are no longer valid.": "请确保将这些代码保存在安全的地方。您的旧备份代码不再有效。",
"Your new backup codes": "您的新备份代码",
"I've saved my backup codes": "我已经保存了我的备份代码",
"Failed to setup MFA": "设置 MFA 失败",
"Setup & Verify": "设置并验证",
"Add to authenticator": "添加到身份验证器",
"1. Scan this QR code with your authenticator app": "1. 用身份验证器应用扫描此二维码",
"Can't scan the code?": "无法扫描代码?",
"Enter this code manually in your authenticator app:": "在您的身份验证器应用中手动输入此代码:",
"2. Enter the 6-digit code from your authenticator": "2. 输入来自身份验证器的6位代码",
"Verify and enable": "验证并启用",
"Failed to generate QR code. Please try again.": "生成二维码失败。请重试。",
"Backup": "备份",
"Save codes": "保存代码",
"Save your backup codes": "保存您的备份代码",
"These codes can be used to access your account if you lose access to your authenticator app. Each code can only be used once.": "如果无法访问身份验证器应用,可以使用这些代码访问账户。每个代码仅可使用一次。",
"Print": "打印",
"Two-factor authentication has been set up. Please log in again.": "双因素认证已设置。请重新登录。",
"Two-Factor authentication required": "需要双因素认证",
"Your workspace requires two-factor authentication for all users": "您的工作区要求所有用户启用双因素认证",
"To continue accessing your workspace, you must set up two-factor authentication. This adds an extra layer of security to your account.": "要继续访问工作区,必须设置双因素认证。此操作为您的账户添加一层额外的安全保障。",
"Set up two-factor authentication": "设置双因素认证",
"Cancel and logout": "取消并退出登录",
"Your workspace requires two-factor authentication. Please set it up to continue.": "您的工作区需要双因素认证。请设置以继续。",
"This adds an extra layer of security to your account by requiring a verification code from your authenticator app.": "通过要求您的身份验证器应用提供验证码,此操作为您的账户增加了一层额外的安全保障。",
"Password is required": "需要密码",
"Password must be at least 8 characters": "密码必须至少包含8个字符",
"Please enter a 6-digit code": "请输入6位代码",
"Code must be exactly 6 digits": "代码必须正好是6位",
"Enter the 6-digit code found in your authenticator app": "输入在您的身份验证器应用中找到的6位代码",
"Need help authenticating?": "需要帮助进行身份验证吗?",
"MFA QR Code": "MFA二维码",
"Account created successfully. Please log in to set up two-factor authentication.": "账户创建成功。请登录以设置双因素认证。",
"Password reset successful. Please log in with your new password and complete two-factor authentication.": "密码重置成功。请使用新密码登录并完成双因素认证。",
"Password reset successful. Please log in with your new password to set up two-factor authentication.": "密码重置成功。请使用新密码登录以设置双因素认证。",
"Password reset was successful. Please log in with your new password.": "密码重置成功。请使用新密码登录。",
"Two-factor authentication": "双因素认证",
"Use authenticator app instead": "改用身份验证器应用",
"Verify backup code": "验证备份代码",
"Use backup code": "使用备份代码",
"Enter one of your backup codes": "输入您的一个备份代码",
"Backup code": "备份代码",
"Enter one of your backup codes. Each backup code can only be used once.": "输入您的一个备份代码。每个备份代码只能使用一次。",
"Verify": "验证",
"Trash": "垃圾箱",
"Pages in trash will be permanently deleted after 30 days.": "垃圾箱中的页面将在30天后被永久删除。",
"Deleted": "已删除",
"No pages in trash": "垃圾箱中没有页面",
"Permanently delete page?": "永久删除页面?",
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "确定要永久删除“{{title}}”吗?此操作无法撤销。",
"Restore '{{title}}' and its sub-pages?": "恢复“{{title}}”及其子页面?",
"Move to trash": "移至垃圾箱",
"Move this page to trash?": "将此页面移至垃圾箱?",
"Restore page": "恢复页面",
"Page moved to trash": "页面已移至垃圾箱",
"Page restored successfully": "页面恢复成功",
"Deleted by": "删除人",
"Deleted at": "删除时间",
"Preview": "预览",
"Subpages": "子页面",
"Failed to load subpages": "加载子页面失败",
"No subpages": "没有子页面",
"Subpages (Child pages)": "子页面(子页面)",
"List all subpages of the current page": "列出当前页面的所有子页面",
"Attachments": "附件",
"All spaces": "所有空间",
"Unknown": "未知",
"Find a space": "查找空间",
"Search in all your spaces": "在您的所有空间中搜索",
"Type": "类型",
"Enterprise": "企业",
"Download attachment": "下载附件",
"Allowed email domains": "允许的电子邮件域",
"Only users with email addresses from these domains can signup via SSO.": "只有来自这些域的电子邮件地址的用户才能通过SSO注册。",
"Enter valid domain names separated by comma or space": "输入用逗号或空格分隔的有效域名",
"Enforce two-factor authentication": "强制实施双因素认证",
"Once enforced, all members must enable two-factor authentication to access the workspace.": "一旦实施,所有成员必须启用双因素认证才能访问工作区。",
"Toggle MFA enforcement": "切换多因素认证实施",
"Display name": "显示名称",
"Allow signup": "允许注册",
"Enabled": "已启用",
"Advanced Settings": "高级设置",
"Enable TLS/SSL": "启用TLS/SSL",
"Use secure connection to LDAP server": "使用安全连接到LDAP服务器",
"Group sync": "组同步",
"No SSO providers found.": "未找到SSO提供商。",
"Delete SSO provider": "删除SSO提供商",
"Are you sure you want to delete this SSO provider?": "您确定要删除此SSO提供商吗",
"Action": "操作",
"{{ssoProviderType}} configuration": "{{ssoProviderType}} 配置"
}

View File

@ -0,0 +1,30 @@
{
"name": "Docmost",
"short_name": "Docmost",
"start_url": "/",
"display": "standalone",
"background_color": "#222",
"theme_color": "#222",
"icons": [
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "icons/favicon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "icons/app-icon-192x192.png",
"type": "image/png",
"sizes": "180x180 192x192"
},
{
"src": "icons/app-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@ -34,7 +34,9 @@ import { useTrackOrigin } from "@/hooks/use-track-origin";
import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/trash.tsx";
import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
export default function App() {
const { t } = useTranslation();
@ -50,10 +52,7 @@ export default function App() {
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route
path={"/login/mfa/setup"}
element={<MfaSetupRequiredPage />}
/>
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
@ -99,8 +98,10 @@ export default function App() {
path={"account/preferences"}
element={<AccountPreferences />}
/>
<Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />

View File

@ -0,0 +1,165 @@
import React, { useRef } from "react";
import { Menu, Box, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { IconTrash, IconUpload } from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { notifications } from "@mantine/notifications";
interface AvatarUploaderProps {
currentImageUrl?: string | null;
fallbackName?: string;
radius?: string | number;
size?: string | number;
variant?: string;
type: AvatarIconType;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
isLoading?: boolean;
disabled?: boolean;
}
export default function AvatarUploader({
currentImageUrl,
fallbackName,
radius,
variant,
size,
type,
onUpload,
onRemove,
isLoading = false,
disabled = false,
}: AvatarUploaderProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file || disabled) {
return;
}
// Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) {
notifications.show({
message: t("Image exceeds 10MB limit."),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
try {
await onUpload(file);
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to upload image"),
color: "red",
});
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleUploadClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
} else {
console.error("File input ref is null!");
}
};
const handleRemove = async () => {
if (disabled) return;
try {
await onRemove();
notifications.show({
message: t("Image removed successfully"),
});
} catch (error) {
console.error(error);
notifications.show({
message: t("Failed to remove image"),
color: "red",
});
}
};
return (
<Box>
<input
type="file"
ref={fileInputRef}
onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg"
style={{ display: "none" }}
/>
<Menu shadow="md" width={200} withArrow disabled={disabled || isLoading}>
<Menu.Target>
<Box style={{ position: "relative", display: "inline-block" }}>
<CustomAvatar
component="button"
size={size}
avatarUrl={currentImageUrl}
name={fallbackName}
style={{
cursor: disabled || isLoading ? "default" : "pointer",
opacity: isLoading ? 0.6 : 1,
}}
radius={radius}
variant={variant}
type={type}
/>
{isLoading && (
<Box
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 1000,
}}
>
<Loader size="sm" />
</Box>
)}
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconUpload size={16} />}
disabled={isLoading || disabled}
onClick={handleUploadClick}
>
{t("Upload image")}
</Menu.Item>
{currentImageUrl && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={handleRemove}
disabled={isLoading || disabled}
>
{t("Remove image")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
}

View File

@ -29,19 +29,22 @@ export default function ExportModal({
}: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
const { t } = useTranslation();
const handleExport = async () => {
try {
if (type === "page") {
await exportPage({ pageId: id, format, includeChildren });
await exportPage({
pageId: id,
format,
includeChildren,
includeAttachments,
});
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
}
setIncludeChildren(false);
setIncludeAttachments(true);
onClose();
} catch (err) {
notifications.show({
@ -96,6 +99,18 @@ export default function ExportModal({
checked={includeChildren}
/>
</Group>
<Group justify="space-between" wrap="nowrap" mt="md">
<div>
<Text size="md">{t("Include attachments")}</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</>
)}

View File

@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps {
colSpan: number;
text?: string;
}
export default function NoTableResults({ colSpan }: NoTableResultsProps) {
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
const { t } = useTranslation();
return (
<Table.Tr>
<Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center">
{t("No results found...")}
{text || t("No results found...")}
</Text>
</Table.Td>
</Table.Tr>

View File

@ -1,10 +1,10 @@
import { Group, Text } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import { User } from "server/dist/database/types/entity.types";
import { IUser } from '@/features/user/types/user.types.ts';
interface UserInfoProps {
user: User;
user: Partial<IUser>;
size?: string;
}
export function UserInfo({ user, size }: UserInfoProps) {

View File

@ -14,6 +14,14 @@ import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
import { useTranslation } from "react-i18next";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { isCloud } from "@/lib/config.ts";
import {
SearchControl,
SearchMobileControl,
} from "@/features/search/components/search-control.tsx";
import {
searchSpotlight,
shareSearchSpotlight,
} from "@/features/search/constants.ts";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@ -79,6 +87,15 @@ export function AppHeader() {
</Group>
</Group>
<div>
<Group visibleFrom="sm">
<SearchControl onClick={searchSpotlight.open} />
</Group>
<Group hiddenFrom="sm">
<SearchMobileControl onSearch={searchSpotlight.open} />
</Group>
</div>
<Group px={"xl"} wrap="nowrap">
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge

View File

@ -1,16 +1,23 @@
import { UserProvider } from "@/features/user/user-provider.tsx";
import { Outlet } from "react-router-dom";
import { Outlet, useParams } from "react-router-dom";
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
import { PosthogUser } from "@/ee/components/posthog-user.tsx";
import { isCloud } from "@/lib/config.ts";
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
import React from "react";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
export default function Layout() {
const { spaceSlug } = useParams();
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
return (
<UserProvider>
<GlobalAppShell>
<Outlet />
</GlobalAppShell>
{isCloud() && <PosthogUser />}
<SearchSpotlight spaceId={space?.id} />
</UserProvider>
);
}

View File

@ -1,8 +1,8 @@
import {
Group,
Menu,
UnstyledButton,
Text,
UnstyledButton,
useMantineColorScheme,
} from "@mantine/core";
import {
@ -10,7 +10,6 @@ import {
IconBrush,
IconCheck,
IconChevronDown,
IconChevronRight,
IconDeviceDesktop,
IconLogout,
IconMoon,
@ -26,6 +25,7 @@ import APP_ROUTE from "@/lib/app-route.ts";
import useAuth from "@/features/auth/hooks/use-auth.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { useTranslation } from "react-i18next";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
export default function TopMenu() {
const { t } = useTranslation();
@ -50,6 +50,7 @@ export default function TopMenu() {
name={workspace?.name}
variant="filled"
size="sm"
type={AvatarIconType.WORKSPACE_ICON}
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace?.name}

View File

@ -50,7 +50,7 @@ export default function AppVersion() {
href="https://github.com/docmost/docmost/releases"
target="_blank"
>
v{APP_VERSION}
{appVersion?.currentVersion && <>v{appVersion?.currentVersion}</>}
</Text>
</Indicator>
</Tooltip>

View File

@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
@ -65,3 +66,17 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};
export const prefetchApiKeys = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1 }),
});
};
export const prefetchApiKeyManagement = () => {
queryClient.prefetchQuery({
queryKey: ["api-key-list", { page: 1 }],
queryFn: () => getApiKeys({ page: 1, adminView: true }),
});
};

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
import { Group, Text, ScrollArea, ActionIcon, Tooltip } from "@mantine/core";
import {
IconUser,
IconSettings,
@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
prefetchApiKeyManagement,
prefetchApiKeys,
prefetchBilling,
prefetchGroups,
prefetchLicense,
@ -42,6 +44,7 @@ interface DataItem {
isEnterprise?: boolean;
isAdmin?: boolean;
isSelfhosted?: boolean;
showDisabledInNonEE?: boolean;
}
interface DataGroup {
@ -59,6 +62,14 @@ const groupedData: DataGroup[] = [
icon: IconBrush,
path: "/settings/account/preferences",
},
{
label: "API keys",
icon: IconKey,
path: "/settings/account/api-keys",
isCloud: true,
isEnterprise: true,
showDisabledInNonEE: true,
},
],
},
{
@ -84,10 +95,20 @@ const groupedData: DataGroup[] = [
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
{
label: "API management",
icon: IconKey,
path: "/settings/api-keys",
isCloud: true,
isEnterprise: true,
isAdmin: true,
showDisabledInNonEE: true,
},
],
},
{
@ -117,6 +138,11 @@ export default function SettingsSidebar() {
}, [location.pathname]);
const canShowItem = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) {
// Check admin permission regardless of license
return item.isAdmin ? isAdmin : true;
}
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return item.isAdmin ? isAdmin : true;
@ -141,6 +167,13 @@ export default function SettingsSidebar() {
return true;
};
const isItemDisabled = (item: DataItem) => {
if (item.showDisabledInNonEE && item.isEnterprise) {
return !(isCloud() || workspace?.hasLicenseKey);
}
return false;
};
const menuItems = groupedData.map((group) => {
if (group.heading === "System" && (!isAdmin || isCloud())) {
return null;
@ -181,27 +214,58 @@ export default function SettingsSidebar() {
case "Public sharing":
prefetchHandler = prefetchShares;
break;
case "API keys":
prefetchHandler = prefetchApiKeys;
break;
case "API management":
prefetchHandler = prefetchApiKeyManagement;
break;
default:
break;
}
return (
const isDisabled = isItemDisabled(item);
const linkElement = (
<Link
onMouseEnter={prefetchHandler}
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
data-disabled={isDisabled || undefined}
key={item.label}
to={item.path}
onClick={() => {
to={isDisabled ? "#" : item.path}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
return;
}
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
style={{
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? "not-allowed" : "pointer",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
if (isDisabled) {
return (
<Tooltip
key={item.label}
label={t("Available in enterprise edition")}
position="right"
withArrow
>
{linkElement}
</Tooltip>
);
}
return linkElement;
})}
</div>
);

View File

@ -1,6 +1,7 @@
import React from "react";
import { Avatar } from "@mantine/core";
import { getAvatarUrl } from "@/lib/config.ts";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
interface CustomAvatarProps {
avatarUrl: string;
@ -11,13 +12,15 @@ interface CustomAvatarProps {
variant?: string;
style?: any;
component?: any;
type?: AvatarIconType;
mt?: string | number;
}
export const CustomAvatar = React.forwardRef<
HTMLInputElement,
CustomAvatarProps
>(({ avatarUrl, name, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl);
>(({ avatarUrl, name, type, ...props }: CustomAvatarProps, ref) => {
const avatarLink = getAvatarUrl(avatarUrl, type);
return (
<Avatar

View File

@ -15,6 +15,11 @@ export interface EmojiPickerInterface {
icon: ReactNode;
removeEmojiAction: () => void;
readOnly: boolean;
actionIconProps?: {
size?: string;
variant?: string;
c?: string;
};
}
function EmojiPicker({
@ -22,6 +27,7 @@ function EmojiPicker({
icon,
removeEmojiAction,
readOnly,
actionIconProps,
}: EmojiPickerInterface) {
const { t } = useTranslation();
const [opened, handlers] = useDisclosure(false);
@ -64,7 +70,12 @@ function EmojiPicker({
closeOnEscape={true}
>
<Popover.Target ref={setTarget}>
<ActionIcon c="gray" variant="transparent" onClick={handlers.toggle}>
<ActionIcon
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
onClick={handlers.toggle}
>
{icon}
</ActionIcon>
</Popover.Target>

View File

@ -0,0 +1,47 @@
import { Box } from "@mantine/core";
import React from "react";
interface ResponsiveSettingsRowProps {
children: React.ReactNode;
}
export function ResponsiveSettingsRow({ children }: ResponsiveSettingsRowProps) {
return (
<Box
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
{children}
</Box>
);
}
interface ResponsiveSettingsContentProps {
children: React.ReactNode;
}
export function ResponsiveSettingsContent({ children }: ResponsiveSettingsContentProps) {
return (
<Box style={{ flex: "1 1 300px", minWidth: 0 }}>
{children}
</Box>
);
}
interface ResponsiveSettingsControlProps {
children: React.ReactNode;
}
export function ResponsiveSettingsControl({ children }: ResponsiveSettingsControlProps) {
return (
<Box style={{ flex: "0 0 auto" }}>
{children}
</Box>
);
}

View File

@ -0,0 +1,72 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { IApiKey } from "@/ee/api-key";
import CopyTextButton from "@/components/common/copy.tsx";
interface ApiKeyCreatedModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey;
}
export function ApiKeyCreatedModal({
opened,
onClose,
apiKey,
}: ApiKeyCreatedModalProps) {
const { t } = useTranslation();
if (!apiKey) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("API key")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{
flex: 1,
}}
value={apiKey.token}
readOnly
/>
<CopyTextButton text={apiKey.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
</Button>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,143 @@
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 { IApiKey } from "@/ee/api-key";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
interface ApiKeyTableProps {
apiKeys: IApiKey[];
isLoading?: boolean;
showUserColumn?: boolean;
onUpdate?: (apiKey: IApiKey) => void;
onRevoke?: (apiKey: IApiKey) => void;
}
export function ApiKeyTable({
apiKeys,
isLoading,
showUserColumn = false,
onUpdate,
onRevoke,
}: ApiKeyTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys && apiKeys.length > 0 ? (
apiKeys.map((apiKey: IApiKey, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Text fz="sm" fw={500}>
{apiKey.name}
</Text>
</Table.Td>
{showUserColumn && apiKey.creator && (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={apiKey.creator?.avatarUrl}
name={apiKey.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{apiKey.creator.name}
</Text>
</Group>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
{apiKey.expiresAt ? (
isExpired(apiKey.expiresAt) ? (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Expired")}
</Text>
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.expiresAt)}
</Text>
)
) : (
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{t("Never")}
</Text>
)}
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(apiKey.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(apiKey)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(apiKey)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -0,0 +1,153 @@
import { lazy, Suspense, useState } from "react";
import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IconCalendar } from "@tabler/icons-react";
import { IApiKey } from "@/ee/api-key";
const DateInput = lazy(() =>
import("@mantine/dates").then((module) => ({
default: module.DateInput,
})),
);
interface CreateApiKeyModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IApiKey) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateApiKeyModal({
opened,
onClose,
onSuccess,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [expirationOption, setExpirationOption] = useState<string>("30");
const createApiKeyMutation = useCreateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
expiresAt: "",
},
});
const getExpirationDate = (): string | undefined => {
if (expirationOption === "never") {
return undefined;
}
if (expirationOption === "custom") {
return form.values.expiresAt;
}
const days = parseInt(expirationOption);
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString();
};
const getExpirationLabel = (days: number) => {
const date = new Date();
date.setDate(date.getDate() + days);
const formatted = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
return `${days} days (${formatted})`;
};
const expirationOptions = [
{ value: "30", label: getExpirationLabel(30) },
{ value: "60", label: getExpirationLabel(60) },
{ value: "90", label: getExpirationLabel(90) },
{ value: "365", label: getExpirationLabel(365) },
{ value: "custom", label: t("Custom") },
{ value: "never", label: t("No expiration") },
];
const handleSubmit = async (data: {
name?: string;
expiresAt?: string | Date;
}) => {
const groupData = {
name: data.name,
expiresAt: getExpirationDate(),
};
try {
const createdKey = await createApiKeyMutation.mutateAsync(groupData);
onSuccess(createdKey);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
setExpirationOption("30");
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
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")}
/>
<Select
label={t("Expiration")}
data={expirationOptions}
value={expirationOption}
onChange={(value) => setExpirationOption(value || "30")}
leftSection={<IconCalendar size={16} />}
allowDeselect={false}
/>
{expirationOption === "custom" && (
<Suspense fallback={null}>
<DateInput
label={t("Custom expiration date")}
placeholder={t("Select expiration date")}
minDate={new Date()}
{...form.getInputProps("expiresAt")}
/>
</Suspense>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createApiKeyMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -0,0 +1,62 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
interface RevokeApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function RevokeApiKeyModal({
opened,
onClose,
apiKey,
}: RevokeApiKeyModalProps) {
const { t } = useTranslation();
const revokeApiKeyMutation = useRevokeApiKeyMutation();
const handleRevoke = async () => {
if (!apiKey) return;
await revokeApiKeyMutation.mutateAsync({
apiKeyId: apiKey.id,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Any applications using this API key will stop working.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeApiKeyMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,80 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
import { IApiKey } from "@/ee/api-key";
import { useEffect } from "react";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateApiKeyModalProps {
opened: boolean;
onClose: () => void;
apiKey: IApiKey | null;
}
export function UpdateApiKeyModal({
opened,
onClose,
apiKey,
}: UpdateApiKeyModalProps) {
const { t } = useTranslation();
const updateApiKeyMutation = useUpdateApiKeyMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
name: "",
},
});
useEffect(() => {
if (opened && apiKey) {
form.setValues({ name: apiKey.name });
}
}, [opened, apiKey]);
const handleSubmit = async (data: { name?: string }) => {
const apiKeyData = {
apiKeyId: apiKey.id,
name: data.name,
};
await updateApiKeyMutation.mutateAsync(apiKeyData);
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive token name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateApiKeyMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}

View File

@ -0,0 +1,11 @@
export { ApiKeyTable } from "./components/api-key-table";
export { CreateApiKeyModal } from "./components/create-api-key-modal";
export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
export { UpdateApiKeyModal } from "./components/update-api-key-modal";
export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
// Services
export * from "./services/api-key-service";
// Types
export * from "./types/api-key.types";

View File

@ -0,0 +1,106 @@
import React, { useState } from "react";
import { Button, Group, Space } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
export default function UserApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page });
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API keys")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API keys")} />
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items || []}
isLoading={isLoading}
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -0,0 +1,117 @@
import React, { useState } from "react";
import { Button, Group, Space, Text } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
import { getAppName } from "@/lib/config";
import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
import Paginate from "@/components/common/paginate";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
import { IApiKey } from "@/ee/api-key";
import useUserRole from '@/hooks/use-user-role.tsx';
export default function WorkspaceApiKeys() {
const { t } = useTranslation();
const { page, setPage } = usePaginateAndSearch();
const [createModalOpened, setCreateModalOpened] = useState(false);
const [createdApiKey, setCreatedApiKey] = useState<IApiKey | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(null);
const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
const handleCreateSuccess = (response: IApiKey) => {
setCreatedApiKey(response);
};
const handleUpdate = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setUpdateModalOpened(true);
};
const handleRevoke = (apiKey: IApiKey) => {
setSelectedApiKey(apiKey);
setRevokeModalOpened(true);
};
return (
<>
<Helmet>
<title>
{t("API management")} - {getAppName()}
</title>
</Helmet>
<SettingsTitle title={t("API management")} />
<Text size="md" c="dimmed" mb="md">
{t("Manage API keys for all users in the workspace")}
</Text>
<Group justify="flex-end" mb="md">
<Button onClick={() => setCreateModalOpened(true)}>
{t("Create API Key")}
</Button>
</Group>
<ApiKeyTable
apiKeys={data?.items}
isLoading={isLoading}
showUserColumn
onUpdate={handleUpdate}
onRevoke={handleRevoke}
/>
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
<CreateApiKeyModal
opened={createModalOpened}
onClose={() => setCreateModalOpened(false)}
onSuccess={handleCreateSuccess}
/>
<ApiKeyCreatedModal
opened={!!createdApiKey}
onClose={() => setCreatedApiKey(null)}
apiKey={createdApiKey}
/>
<UpdateApiKeyModal
opened={updateModalOpened}
onClose={() => {
setUpdateModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
<RevokeApiKeyModal
opened={revokeModalOpened}
onClose={() => {
setRevokeModalOpened(false);
setSelectedApiKey(null);
}}
apiKey={selectedApiKey}
/>
</>
);
}

View File

@ -0,0 +1,97 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createApiKey,
getApiKeys,
IApiKey,
ICreateApiKeyRequest,
IUpdateApiKeyRequest,
revokeApiKey,
updateApiKey,
} from "@/ee/api-key";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetApiKeysQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IApiKey>, Error> {
return useQuery({
queryKey: ["api-key-list", params],
queryFn: () => getApiKeys(params),
staleTime: 0,
gcTime: 0,
placeholderData: keepPreviousData,
});
}
export function useRevokeApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
void,
Error,
{
apiKeyId: string;
}
>({
mutationFn: (data) => revokeApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useCreateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateApiKeyMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IApiKey, Error, IUpdateApiKeyRequest>({
mutationFn: (data) => updateApiKey(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@ -0,0 +1,32 @@
import api from "@/lib/api-client";
import {
ICreateApiKeyRequest,
IApiKey,
IUpdateApiKeyRequest,
} from "@/ee/api-key/types/api-key.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getApiKeys(
params?: QueryParams,
): Promise<IPagination<IApiKey>> {
const req = await api.post("/api-keys", { ...params });
return req.data;
}
export async function createApiKey(
data: ICreateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/create", data);
return req.data;
}
export async function updateApiKey(
data: IUpdateApiKeyRequest,
): Promise<IApiKey> {
const req = await api.post<IApiKey>("/api-keys/update", data);
return req.data;
}
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
await api.post("/api-keys/revoke", data);
}

View File

@ -0,0 +1,23 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IApiKey {
id: string;
name: string;
token?: string;
creatorId: string;
workspaceId: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
creator: Partial<IUser>;
}
export interface ICreateApiKeyRequest {
name: string;
expiresAt?: string;
}
export interface IUpdateApiKeyRequest {
apiKeyId: string;
name: string;
}

View File

@ -0,0 +1,124 @@
import React, { useState } from "react";
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
password: z.string().min(1, { message: "Password is required" }),
});
interface LdapLoginModalProps {
opened: boolean;
onClose: () => void;
provider: IAuthProvider;
workspaceId: string;
}
export function LdapLoginModal({
opened,
onClose,
provider,
workspaceId,
}: LdapLoginModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
username: "",
password: "",
},
});
const handleSubmit = async (values: {
username: string;
password: string;
}) => {
setIsLoading(true);
setError(null);
try {
const response = await ldapLogin({
username: values.username,
password: values.password,
providerId: provider.id,
workspaceId,
});
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else {
onClose();
navigate(APP_ROUTE.HOME);
}
} catch (err: any) {
setIsLoading(false);
const errorMessage =
err.response?.data?.message || "Authentication failed";
setError(errorMessage);
notifications.show({
message: errorMessage,
color: "red",
});
}
};
const handleClose = () => {
form.reset();
setError(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={`LDAP Login - ${provider.name}`}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
id="ldap-username"
type="text"
label={t("LDAP username")}
placeholder="Enter your LDAP username"
variant="filled"
disabled={isLoading}
data-autofocus
{...form.getInputProps("username")}
/>
<PasswordInput
label={t("LDAP password")}
placeholder={t("Enter your LDAP password")}
variant="filled"
disabled={isLoading}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign in with LDAP")}
</Button>
</Stack>
</form>
</Modal>
);
}

View File

@ -1,29 +1,62 @@
import { useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock } from "@tabler/icons-react";
import { IconLock, IconServer } from "@tabler/icons-react";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => {
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
if (provider.type === SSO_PROVIDER.LDAP) {
// Open modal for LDAP instead of redirecting
setSelectedLdapProvider(provider);
setLdapModalOpened(true);
} else {
// Redirect for other SSO providers
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
}
};
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
} else if (provider.type === SSO_PROVIDER.LDAP) {
return <IconServer size={16} />;
} else {
return <IconLock size={16} />;
}
};
return (
<>
{selectedLdapProvider && (
<LdapLoginModal
opened={ldapModalOpened}
onClose={() => {
setLdapModalOpened(false);
setSelectedLdapProvider(null);
}}
provider={selectedLdapProvider}
workspaceId={data.id}
/>
)}
{(isCloud() || data.hasLicenseKey) && (
<>
<Stack align="stretch" justify="center" gap="sm">
@ -31,13 +64,7 @@ export default function SsoLogin() {
<div key={provider.id}>
<Button
onClick={() => handleSsoLogin(provider)}
leftSection={
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
leftSection={getProviderIcon(provider)}
variant="default"
fullWidth
>

View File

@ -11,7 +11,7 @@ export default function OssDetails() {
withTableBorder
>
<Table.Caption>
To unlock enterprise features like SSO, contact sales@docmost.com.
To unlock enterprise features like SSO, MFA, Resolve comments, contact sales@docmost.com.
</Table.Caption>
<Table.Tbody>
<Table.Tr>

View File

@ -45,6 +45,7 @@ export function MfaBackupCodeInput({
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
error={error}
autoFocus
data-autofocus
maxLength={8}
styles={{
input: {

View File

@ -25,23 +25,30 @@ import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaBackupCodesModalProps {
opened: boolean;
onClose: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaBackupCodesModal({
opened,
onClose,
}: MfaBackupCodesModalProps) {
const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showNewCodes, setShowNewCodes] = useState(false);
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({
validate: zodResolver(formSchema),
@ -51,7 +58,7 @@ export function MfaBackupCodesModal({
});
const regenerateMutation = useMutation({
mutationFn: (data: { confirmPassword: string }) =>
mutationFn: (data: { confirmPassword?: string }) =>
regenerateBackupCodes(data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes);
@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
},
});
const handleRegenerate = (values: { confirmPassword: string }) => {
regenerateMutation.mutate(values);
const handleRegenerate = (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
regenerateMutation.mutate(payload);
};
const handleClose = () => {
@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
)}
</Text>
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
/>
{requiresPassword && (
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
)}
<Button
type="submit"

View File

@ -97,6 +97,7 @@ export function MfaChallenge() {
length={6}
type="number"
autoFocus
data-autofocus
oneTimeCode
{...form.getInputProps("code")}
error={!!form.errors.code}

View File

@ -15,6 +15,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { disableMfa } from "@/ee/mfa";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaDisableModalProps {
opened: boolean;
@ -22,16 +23,22 @@ interface MfaDisableModalProps {
onComplete: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaDisableModal({
opened,
onClose,
onComplete,
}: MfaDisableModalProps) {
const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({
validate: zodResolver(formSchema),
@ -54,8 +61,12 @@ export function MfaDisableModal({
},
});
const handleSubmit = async (values: { confirmPassword: string }) => {
await disableMutation.mutateAsync(values);
const handleSubmit = async (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
};
const handleClose = () => {
@ -85,18 +96,23 @@ export function MfaDisableModal({
</Text>
</Alert>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
{requiresPassword && (
<>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
</>
)}
<Stack gap="sm">
<Button

View File

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { Group, Text, Button } from "@mantine/core";
import { Group, Text, Button, Tooltip } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
@ -7,6 +7,9 @@ import { getMfaStatus } from "@/ee/mfa";
import { MfaSetupModal } from "@/ee/mfa";
import { MfaDisableModal } from "@/ee/mfa";
import { MfaBackupCodesModal } from "@/ee/mfa";
import { isCloud } from "@/lib/config.ts";
import useLicense from "@/ee/hooks/use-license.tsx";
import { ResponsiveSettingsRow, ResponsiveSettingsContent, ResponsiveSettingsControl } from "@/components/ui/responsive-settings-row";
export function MfaSettings() {
const { t } = useTranslation();
@ -14,16 +17,19 @@ export function MfaSettings() {
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [disableModalOpen, setDisableModalOpen] = useState(false);
const [backupCodesModalOpen, setBackupCodesModalOpen] = useState(false);
const { hasLicenseKey } = useLicense();
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ["mfa-status"],
queryFn: getMfaStatus,
});
if (isLoading) {
if (isLoading || !mfaStatus) {
return null;
}
const canUseMfa = isCloud() || hasLicenseKey;
// Check if MFA is truly enabled
const isMfaEnabled = mfaStatus?.isEnabled === true;
@ -48,8 +54,8 @@ export function MfaSettings() {
return (
<>
<Group justify="space-between" wrap="nowrap" gap="xl">
<div style={{ minWidth: 0, flex: 1 }}>
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t("2-step verification")}</Text>
<Text size="sm" c="dimmed">
{!isMfaEnabled
@ -58,38 +64,46 @@ export function MfaSettings() {
)
: t("Two-factor authentication is active on your account.")}
</Text>
</div>
</ResponsiveSettingsContent>
{!isMfaEnabled ? (
<Button
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Add 2FA method")}
</Button>
) : (
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
size="sm"
onClick={() => setBackupCodesModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
<ResponsiveSettingsControl>
{!isMfaEnabled ? (
<Tooltip
label={t("Available in enterprise edition")}
disabled={canUseMfa}
>
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
</Button>
<Button
variant="default"
size="sm"
color="red"
onClick={() => setDisableModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Disable")}
</Button>
</Group>
)}
</Group>
<Button
disabled={!canUseMfa}
variant="default"
onClick={() => setSetupModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Add 2FA method")}
</Button>
</Tooltip>
) : (
<Group gap="sm" wrap="nowrap">
<Button
variant="default"
size="sm"
onClick={() => setBackupCodesModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Backup codes")} ({mfaStatus?.backupCodesCount || 0})
</Button>
<Button
variant="default"
size="sm"
color="red"
onClick={() => setDisableModalOpen(true)}
style={{ whiteSpace: "nowrap" }}
>
{t("Disable")}
</Button>
</Group>
)}
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
<MfaSetupModal
opened={setupModalOpen}

View File

@ -235,6 +235,7 @@ export function MfaSetupModal({
length={6}
type="number"
autoFocus
data-autofocus
oneTimeCode
{...form.getInputProps("verificationCode")}
styles={{

View File

@ -37,7 +37,7 @@ export async function disableMfa(
}
export async function regenerateBackupCodes(data: {
confirmPassword: string;
confirmPassword?: string;
}): Promise<MfaBackupCodesResponse> {
const req = await api.post<MfaBackupCodesResponse>(
"/mfa/generate-backup-codes",

View File

@ -46,7 +46,7 @@ export interface MfaEnableResponse {
}
export interface MfaDisableRequest {
confirmPassword: string;
confirmPassword?: string;
}
export interface MfaBackupCodesResponse {

View File

@ -1,6 +1,7 @@
import { useAtom } from "jotai";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core";
@ -54,9 +55,11 @@ export default function AllowedDomains() {
return (
<>
<div>
<Text size="md">Allowed email domains</Text>
<Text size="md">{t("Allowed email domains")}</Text>
<Text size="sm" c="dimmed">
Only users with email addresses from these domains can signup via SSO.
{t(
"Only users with email addresses from these domains can signup via SSO.",
)}
</Text>
</div>
<form onSubmit={form.onSubmit(handleSubmit)}>

View File

@ -1,7 +1,7 @@
import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { Button, Menu, Group } from "@mantine/core";
import { IconChevronDown, IconLock } from "@tabler/icons-react";
import { IconChevronDown, IconLock, IconServer } from "@tabler/icons-react";
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
@ -40,6 +40,19 @@ export default function CreateSsoProvider() {
}
};
const handleCreateLDAP = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.LDAP,
name: "LDAP",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create LDAP provider", error);
}
};
return (
<>
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
@ -71,6 +84,13 @@ export default function CreateSsoProvider() {
>
OpenID (OIDC)
</Menu.Item>
<Menu.Item
onClick={handleCreateLDAP}
leftSection={<IconServer size={16} />}
>
LDAP / Active Directory
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>

View File

@ -1,6 +1,7 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";

View File

@ -0,0 +1,228 @@
import React from "react";
import { z } from "zod";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
Group,
Stack,
Switch,
TextInput,
Textarea,
Text,
Accordion,
} from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { IconInfoCircle } from "@tabler/icons-react";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
ldapUrl: z.string().url().startsWith("ldap", "Must be an LDAP URL"),
ldapBindDn: z.string().min(1, "Bind DN is required"),
ldapBindPassword: z.string().min(1, "Bind password is required"),
ldapBaseDn: z.string().min(1, "Base DN is required"),
ldapUserSearchFilter: z.string().optional(),
ldapTlsEnabled: z.boolean(),
ldapTlsCaCert: z.string().optional(),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
ldapUrl: provider.ldapUrl || "",
ldapBindDn: provider.ldapBindDn || "",
ldapBindPassword: provider.ldapBindPassword || "",
ldapBaseDn: provider.ldapBaseDn || "",
ldapUserSearchFilter:
provider.ldapUserSearchFilter || "(mail={{username}})",
ldapTlsEnabled: provider.ldapTlsEnabled || false,
ldapTlsCaCert: provider.ldapTlsCaCert || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("ldapUrl")) {
ssoData.ldapUrl = values.ldapUrl;
}
if (form.isDirty("ldapBindDn")) {
ssoData.ldapBindDn = values.ldapBindDn;
}
if (form.isDirty("ldapBindPassword")) {
ssoData.ldapBindPassword = values.ldapBindPassword;
}
if (form.isDirty("ldapBaseDn")) {
ssoData.ldapBaseDn = values.ldapBaseDn;
}
if (form.isDirty("ldapUserSearchFilter")) {
ssoData.ldapUserSearchFilter = values.ldapUserSearchFilter;
}
if (form.isDirty("ldapTlsEnabled")) {
ssoData.ldapTlsEnabled = values.ldapTlsEnabled;
}
if (form.isDirty("ldapTlsCaCert")) {
ssoData.ldapTlsCaCert = values.ldapTlsCaCert;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("Display name")}
placeholder="e.g Company LDAP"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="LDAP Server URL"
description="URL of your LDAP server"
placeholder="ldap://ldap.example.com:389 or ldaps://ldap.example.com:636"
{...form.getInputProps("ldapUrl")}
/>
<TextInput
label="Bind DN"
description="Distinguished Name of the service account for searching"
placeholder="cn=admin,dc=example,dc=com"
{...form.getInputProps("ldapBindDn")}
/>
<TextInput
label="Bind Password"
description="Password for the service account"
type="password"
placeholder="••••••••"
{...form.getInputProps("ldapBindPassword")}
/>
<TextInput
label="Base DN"
description="Base DN where user searches will start"
placeholder="ou=users,dc=example,dc=com"
{...form.getInputProps("ldapBaseDn")}
/>
<TextInput
label="User Search Filter"
description="LDAP filter to find users. Use {{username}} as placeholder"
placeholder="(mail={{username}})"
{...form.getInputProps("ldapUserSearchFilter")}
/>
<Accordion variant="separated">
<Accordion.Item value="advanced">
<Accordion.Control icon={<IconInfoCircle size={20} />}>
{t("Advanced Settings")}
</Accordion.Control>
<Accordion.Panel>
<Stack>
<Group justify="space-between">
<div>
<Text size="sm">{t("Enable TLS/SSL")}</Text>
<Text size="xs" c="dimmed">
Use secure connection to LDAP server
</Text>
</div>
<Switch
className={classes.switch}
checked={form.values.ldapTlsEnabled}
{...form.getInputProps("ldapTlsEnabled")}
/>
</Group>
{form.values.ldapTlsEnabled && (
<Textarea
label="CA Certificate"
description="PEM-encoded CA certificate for TLS verification (optional)"
placeholder="-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----"
minRows={4}
{...form.getInputProps("ldapTlsCaCert")}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch
className={classes.switch}
checked={form.values.isEnabled}
{...form.getInputProps("isEnabled")}
/>
</Group>
<Group mt="md" justify="flex-end">
<Button type="submit" disabled={!form.isDirty()}>
{t("Save")}
</Button>
</Group>
</Stack>
</form>
</Box>
);
}

View File

@ -16,6 +16,7 @@ const ssoSchema = z.object({
oidcClientSecret: z.string().min(1, "Client secret is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
oidcClientSecret: provider.oidcClientSecret || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
@ -78,7 +83,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
label={t("Display name")}
placeholder="e.g Google SSO"
data-autofocus
{...form.getInputProps("name")}
@ -110,6 +115,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("oidcClientSecret")}
/>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch

View File

@ -69,7 +69,7 @@ export default function SsoProviderList() {
return (
<>
<Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={500}>
<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
@ -104,7 +104,7 @@ export default function SsoProviderList() {
</Group>
</Table.Td>
<Table.Td>
<Badge color={"gray"} variant="light">
<Badge color={"gray"} variant="light" style={{ whiteSpace: "nowrap" }}>
{provider.type.toUpperCase()}
</Badge>
</Table.Td>
@ -133,6 +133,7 @@ export default function SsoProviderList() {
)}
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
@ -168,6 +169,7 @@ export default function SsoProviderList() {
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
))}

View File

@ -5,6 +5,8 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
import { SsoLDAPForm } from "@/ee/security/components/sso-ldap-form.tsx";
import { useTranslation } from "react-i18next";
interface SsoModalProps {
opened: boolean;
@ -17,6 +19,8 @@ export default function SsoProviderModal({
onClose,
provider,
}: SsoModalProps) {
const { t } = useTranslation();
if (!provider) {
return null;
}
@ -24,7 +28,9 @@ export default function SsoProviderModal({
return (
<Modal
opened={opened}
title={`${provider.type.toUpperCase()} Configuration`}
title={t("{{ssoProviderType}} configuration", {
ssoProviderType: provider.type.toUpperCase(),
})}
onClose={onClose}
>
{provider.type === SSO_PROVIDER.SAML && (
@ -38,6 +44,10 @@ export default function SsoProviderModal({
{provider.type === SSO_PROVIDER.GOOGLE && (
<SsoGoogleForm provider={provider} onClose={onClose} />
)}
{provider.type === SSO_PROVIDER.LDAP && (
<SsoLDAPForm provider={provider} onClose={onClose} />
)}
</Modal>
);
}

View File

@ -1,6 +1,7 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
@ -26,6 +27,7 @@ const ssoSchema = z.object({
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
@ -45,6 +47,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
samlCertificate: provider.samlCertificate || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
@ -75,6 +78,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
@ -86,7 +92,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
label={t("Display name")}
placeholder="e.g Azure Entra"
data-autofocus
{...form.getInputProps("name")}
@ -123,6 +129,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("samlCertificate")}
/>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch

View File

@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
OIDC = 'oidc',
SAML = 'saml',
GOOGLE = 'google',
LDAP = 'ldap',
}

View File

@ -0,0 +1,23 @@
import api from "@/lib/api-client.ts";
import { ILoginResponse } from "@/features/auth/types/auth.types.ts";
interface ILdapLogin {
username: string;
password: string;
providerId: string;
workspaceId: string;
}
export async function ldapLogin(data: ILdapLogin): Promise<ILoginResponse> {
const requestData = {
username: data.username,
password: data.password,
};
const response = await api.post<ILoginResponse>(
`/sso/ldap/${data.providerId}/login`,
requestData
);
return response.data;
}

View File

@ -9,8 +9,17 @@ export interface IAuthProvider {
oidcIssuer: string;
oidcClientId: string;
oidcClientSecret: string;
ldapUrl: string;
ldapBindDn: string;
ldapBindPassword: string;
ldapBaseDn: string;
ldapUserSearchFilter: string;
ldapUserAttributes: any;
ldapTlsEnabled: boolean;
ldapTlsCaCert: string;
allowSignup: boolean;
isEnabled: boolean;
groupSync: boolean;
creatorId: string;
workspaceId: string;
createdAt: Date;

View File

@ -0,0 +1,64 @@
import api from "@/lib/api-client";
import {
AvatarIconType,
IAttachment,
} from "@/features/attachments/types/attachment.types.ts";
export async function uploadIcon(
file: File,
type: AvatarIconType,
spaceId?: string,
): Promise<IAttachment> {
const formData = new FormData();
formData.append("type", type);
if (spaceId) {
formData.append("spaceId", spaceId);
}
formData.append("image", file);
return await api.post("/attachments/upload-image", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
export async function uploadUserAvatar(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.AVATAR);
}
export async function uploadSpaceIcon(
file: File,
spaceId: string,
): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.SPACE_ICON, spaceId);
}
export async function uploadWorkspaceIcon(file: File): Promise<IAttachment> {
return uploadIcon(file, AvatarIconType.WORKSPACE_ICON);
}
async function removeIcon(
type: AvatarIconType,
spaceId?: string,
): Promise<void> {
const payload: { spaceId?: string; type: string } = { type };
if (spaceId) {
payload.spaceId = spaceId;
}
await api.post("/attachments/remove-icon", payload);
}
export async function removeAvatar(): Promise<void> {
await removeIcon(AvatarIconType.AVATAR);
}
export async function removeSpaceIcon(spaceId: string): Promise<void> {
await removeIcon(AvatarIconType.SPACE_ICON, spaceId);
}
export async function removeWorkspaceIcon(): Promise<void> {
await removeIcon(AvatarIconType.WORKSPACE_ICON);
}

View File

@ -0,0 +1,9 @@
export {
uploadIcon,
uploadUserAvatar,
uploadSpaceIcon,
uploadWorkspaceIcon,
removeAvatar,
removeSpaceIcon,
removeWorkspaceIcon,
} from "./attachment-service.ts";

View File

@ -0,0 +1,29 @@
export interface IAttachment {
id: string;
fileName: string;
filePath: string;
fileSize: number;
fileExt: string;
mimeType: string;
type: string;
creatorId: string;
pageId: string | null;
spaceId: string | null;
workspaceId: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export enum AvatarIconType {
AVATAR = "avatar",
SPACE_ICON = "space-icon",
WORKSPACE_ICON = "workspace-icon",
}
export enum AttachmentType {
AVATAR = "avatar",
WORKSPACE_ICON = "workspace-icon",
SPACE_ICON = "space-icon",
FILE = "file",
}

View File

@ -41,6 +41,7 @@ function CommentListWithTabs() {
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const canComment: boolean = spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page
@ -179,6 +180,17 @@ function CommentListWithTabs() {
userSpaceRole={space?.membership?.role}
/>
</div>
{canComment && (
<>
<Divider my={4} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</>
)}
</Paper>
))}
</div>

View File

@ -3,6 +3,7 @@ import {
BubbleMenuProps,
isNodeSelection,
useEditor,
useEditorState,
} from "@tiptap/react";
import { FC, useEffect, useRef, useState } from "react";
import {
@ -50,34 +51,52 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
if (!props.editor) {
return null;
}
return {
isBold: ctx.editor.isActive("bold"),
isItalic: ctx.editor.isActive("italic"),
isUnderline: ctx.editor.isActive("underline"),
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
};
},
});
const items: BubbleMenuItem[] = [
{
name: "Bold",
isActive: () => props.editor.isActive("bold"),
isActive: () => editorState?.isBold,
command: () => props.editor.chain().focus().toggleBold().run(),
icon: IconBold,
},
{
name: "Italic",
isActive: () => props.editor.isActive("italic"),
isActive: () => editorState?.isItalic,
command: () => props.editor.chain().focus().toggleItalic().run(),
icon: IconItalic,
},
{
name: "Underline",
isActive: () => props.editor.isActive("underline"),
isActive: () => editorState?.isUnderline,
command: () => props.editor.chain().focus().toggleUnderline().run(),
icon: IconUnderline,
},
{
name: "Strike",
isActive: () => props.editor.isActive("strike"),
isActive: () => editorState?.isStrike,
command: () => props.editor.chain().focus().toggleStrike().run(),
icon: IconStrikethrough,
},
{
name: "Code",
isActive: () => props.editor.isActive("code"),
isActive: () => editorState?.isCode,
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
},
@ -85,7 +104,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const commentItem: BubbleMenuItem = {
name: "Comment",
isActive: () => props.editor.isActive("comment"),
isActive: () => editorState?.isComment,
command: () => {
const commentId = uuid7();

View File

@ -9,7 +9,8 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface BubbleColorMenuItem {
@ -18,7 +19,7 @@ export interface BubbleColorMenuItem {
}
interface ColorSelectorProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@ -108,12 +109,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
setIsOpen,
}) => {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: ctx => {
if (!ctx.editor) {
return null;
}
const activeColors: Record<string, boolean> = {};
TEXT_COLORS.forEach(({ color }) => {
activeColors[`text_${color}`] = ctx.editor.isActive("textStyle", { color });
});
HIGHLIGHT_COLORS.forEach(({ color }) => {
activeColors[`highlight_${color}`] = ctx.editor.isActive("highlight", { color });
});
return activeColors;
},
});
if (!editor || !editorState) {
return null;
}
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color }),
editorState[`text_${color}`]
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color }),
editorState[`highlight_${color}`]
);
return (
@ -151,7 +176,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
justify="left"
fullWidth
rightSection={
editor.isActive("textStyle", { color }) && (
editorState[`text_${color}`] && (
<IconCheck style={{ width: rem(16) }} />
)
}

View File

@ -13,11 +13,12 @@ import {
IconTypography,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface NodeSelectorProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@ -36,6 +37,27 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
}) => {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!editor) {
return null;
}
return {
isParagraph: ctx.editor.isActive("paragraph"),
isBulletList: ctx.editor.isActive("bulletList"),
isOrderedList: ctx.editor.isActive("orderedList"),
isHeading1: ctx.editor.isActive("heading", { level: 1 }),
isHeading2: ctx.editor.isActive("heading", { level: 2 }),
isHeading3: ctx.editor.isActive("heading", { level: 3 }),
isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"),
};
},
});
const items: BubbleMenuItem[] = [
{
name: "Text",
@ -43,45 +65,45 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
editorState?.isParagraph &&
!editorState?.isBulletList &&
!editorState?.isOrderedList,
},
{
name: "Heading 1",
icon: IconH1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
isActive: () => editorState?.isHeading1,
},
{
name: "Heading 2",
icon: IconH2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
isActive: () => editorState?.isHeading2,
},
{
name: "Heading 3",
icon: IconH3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
isActive: () => editorState?.isHeading3,
},
{
name: "To-do List",
icon: IconCheckbox,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
isActive: () => editorState?.isTaskItem,
},
{
name: "Bullet List",
icon: IconList,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
isActive: () => editorState?.isBulletList,
},
{
name: "Numbered List",
icon: IconListNumbers,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
isActive: () => editorState?.isOrderedList,
},
{
name: "Blockquote",
@ -93,13 +115,13 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
isActive: () => editor.isActive("blockquote"),
isActive: () => editorState?.isBlockquote,
},
{
name: "Code",
icon: IconCode,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
isActive: () => editorState?.isCodeBlock,
},
];

View File

@ -8,11 +8,12 @@ import {
IconChevronDown,
} from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TextAlignmentProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
@ -31,36 +32,54 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
}) => {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: BubbleMenuItem[] = [
{
name: "Align left",
isActive: () => editor.isActive({ textAlign: "left" }),
isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
isActive: () => editor.isActive({ textAlign: "center" }),
isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
isActive: () => editor.isActive({ textAlign: "right" }),
isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
{
name: "Justify",
isActive: () => editor.isActive({ textAlign: "justify" }),
isActive: () => editorState?.isAlignJustify,
command: () => editor.chain().focus().setTextAlign("justify").run(),
icon: IconAlignJustified,
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
const activeItem = items.filter((item) => item.isActive()).pop() ?? items[0];
return (
<Popover opened={isOpen} withArrow>
@ -73,7 +92,7 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
>
<IconAlignLeft style={{ width: rem(16) }} stroke={2} />
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Popover.Target>

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { Node as PMNode } from "prosemirror-model";
@ -15,12 +16,15 @@ import {
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
IconMoodSmile,
} from "@tabler/icons-react";
import { CalloutType } from "@docmost/editor-ext";
import { useTranslation } from "react-i18next";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
export function CalloutMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
@ -32,6 +36,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isCallout: ctx.editor.isActive("callout"),
isInfo: ctx.editor.isActive("callout", { type: "info" }),
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
@ -56,10 +77,40 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
[editor],
);
const setCalloutIcon = useCallback(
(emoji: any) => {
const emojiChar = emoji?.native || emoji?.emoji || emoji;
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateCalloutIcon(emojiChar)
.run();
},
[editor],
);
const removeCalloutIcon = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateCalloutIcon("")
.run();
}, [editor]);
const getCurrentIcon = () => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "callout";
const parent = findParentNode(predicate)(selection);
const icon = parent?.node.attrs.icon;
return icon || null;
};
const currentIcon = getCurrentIcon();
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`callout-menu}`}
pluginKey={`callout-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
@ -78,9 +129,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("info")}
size="lg"
aria-label={t("Info")}
variant={
editor.isActive("callout", { type: "info" }) ? "light" : "default"
}
variant={editorState?.isInfo ? "light" : "default"}
>
<IconInfoCircleFilled size={18} />
</ActionIcon>
@ -91,11 +140,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("success")}
size="lg"
aria-label={t("Success")}
variant={
editor.isActive("callout", { type: "success" })
? "light"
: "default"
}
variant={editorState?.isSuccess ? "light" : "default"}
>
<IconCircleCheckFilled size={18} />
</ActionIcon>
@ -106,11 +151,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("warning")}
size="lg"
aria-label={t("Warning")}
variant={
editor.isActive("callout", { type: "warning" })
? "light"
: "default"
}
variant={editorState?.isWarning ? "light" : "default"}
>
<IconAlertTriangleFilled size={18} />
</ActionIcon>
@ -121,15 +162,23 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
onClick={() => setCalloutType("danger")}
size="lg"
aria-label={t("Danger")}
variant={
editor.isActive("callout", { type: "danger" })
? "light"
: "default"
}
variant={editorState?.isDanger ? "light" : "default"}
>
<IconCircleXFilled size={18} />
</ActionIcon>
</Tooltip>
<EmojiPicker
onEmojiSelect={setCalloutIcon}
removeEmojiAction={removeCalloutIcon}
readOnly={false}
icon={currentIcon || <IconMoodSmile size={18} />}
actionIconProps={{
size: "lg",
variant: "default",
c: undefined,
}}
/>
</ActionIcon.Group>
</BaseBubbleMenu>
);

View File

@ -11,7 +11,7 @@ import { CalloutType } from "@docmost/editor-ext";
export default function CalloutView(props: NodeViewProps) {
const { node } = props;
const { type } = node.attrs;
const { type, icon } = node.attrs;
return (
<NodeViewWrapper>
@ -19,7 +19,7 @@ export default function CalloutView(props: NodeViewProps) {
variant="light"
title=""
color={getCalloutColor(type)}
icon={getCalloutIcon(type)}
icon={getCalloutIcon(type, icon)}
p="xs"
classNames={{
message: classes.message,
@ -32,7 +32,11 @@ export default function CalloutView(props: NodeViewProps) {
);
}
function getCalloutIcon(type: CalloutType) {
function getCalloutIcon(type: CalloutType, customIcon?: string) {
if (customIcon && customIcon.trim() !== "") {
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
}
switch (type) {
case "info":
return <IconInfoCircleFilled />;

View File

@ -4,11 +4,7 @@ import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css";
import { useTranslation } from "react-i18next";
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
});
import { useComputedColorScheme } from "@mantine/core";
interface MermaidViewProps {
props: NodeViewProps;
@ -16,12 +12,22 @@ interface MermaidViewProps {
export default function MermaidView({ props }: MermaidViewProps) {
const { t } = useTranslation();
const computedColorScheme = useComputedColorScheme();
const { node } = props;
const [preview, setPreview] = useState<string>("");
// Update Mermaid config when theme changes.
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
theme: computedColorScheme === "light" ? "default" : "dark",
});
}, [computedColorScheme]);
// Re-render the diagram whenever the node content or theme changes.
useEffect(() => {
const id = `mermaid-${uuidv4()}`;
if (node.textContent.length > 0) {
mermaid
.render(id, node.textContent)
@ -40,7 +46,7 @@ export default function MermaidView({ props }: MermaidViewProps) {
}
});
}
}, [node.textContent]);
}, [node.textContent, computedColorScheme]);
return (
<div

View File

@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from '@tiptap/react';
import { useCallback } from 'react';
import { sticky } from 'tippy.js';
import { Node as PMNode } from 'prosemirror-model';
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
@ -19,14 +20,29 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
return false;
}
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
},
[editor]
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const drawioAttr = ctx.editor.getAttributes("drawio");
return {
isDrawio: ctx.editor.isActive("drawio"),
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'drawio';
const predicate = (node: PMNode) => node.type.name === "drawio";
const parent = findParentNode(predicate)(selection);
if (parent) {
@ -39,40 +55,37 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes('drawio', { width: `${value}%` });
editor.commands.updateAttributes("drawio", { width: `${value}%` });
},
[editor]
[editor],
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu}`}
pluginKey={`drawio-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: 'popper',
sticky: "popper",
}}
shouldShow={shouldShow}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{editor.getAttributes('drawio')?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>

View File

@ -17,7 +17,7 @@ import {
EventExit,
EventSave,
} from "react-drawio";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
@ -145,6 +145,7 @@ export default function DrawioView(props: NodeViewProps) {
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,

View File

@ -2,15 +2,16 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from '@tiptap/react';
import { useCallback } from 'react';
import { sticky } from 'tippy.js';
import { Node as PMNode } from 'prosemirror-model';
useEditorState,
} from "@tiptap/react";
import { useCallback } from "react";
import { sticky } from "tippy.js";
import { Node as PMNode } from "prosemirror-model";
import {
EditorMenuProps,
ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
} from "@/features/editor/components/table/types/types.ts";
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
@ -19,14 +20,31 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
return false;
}
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
return (
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
);
},
[editor]
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
return {
isExcalidraw: ctx.editor.isActive("excalidraw"),
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'excalidraw';
const predicate = (node: PMNode) => node.type.name === "excalidraw";
const parent = findParentNode(predicate)(selection);
if (parent) {
@ -39,9 +57,9 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
},
[editor]
[editor],
);
return (
@ -54,25 +72,22 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: 'popper',
sticky: "popper",
}}
shouldShow={shouldShow}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{editor.getAttributes('excalidraw')?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</div>
</BaseBubbleMenu>

View File

@ -15,7 +15,7 @@ import { useDisclosure } from "@mantine/hooks";
import { getFileUrl } from "@/lib/config.ts";
import "@excalidraw/excalidraw/index.css";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { IAttachment } from "@/lib/types";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import ReactClearModal from "react-clear-modal";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
@ -183,6 +183,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
variant="default"
color="gray"
mx="xs"
className="print-hide"
style={{
position: "absolute",
top: 8,

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
@ -32,6 +33,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const imageAttrs = ctx.editor.getAttributes("image");
return {
isImage: ctx.editor.isActive("image"),
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "image";
@ -83,7 +103,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`image-menu}`}
pluginKey={`image-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
@ -103,9 +123,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageLeft}
size="lg"
aria-label={t("Align left")}
variant={
editor.isActive("image", { align: "left" }) ? "light" : "default"
}
variant={editorState?.isAlignLeft ? "light" : "default"}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
@ -116,11 +134,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageCenter}
size="lg"
aria-label={t("Align center")}
variant={
editor.isActive("image", { align: "center" })
? "light"
: "default"
}
variant={editorState?.isAlignCenter ? "light" : "default"}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
@ -131,20 +145,15 @@ export function ImageMenu({ editor }: EditorMenuProps) {
onClick={alignImageRight}
size="lg"
aria-label={t("Align right")}
variant={
editor.isActive("image", { align: "right" }) ? "light" : "default"
}
variant={editorState?.isAlignRight ? "light" : "default"}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editor.getAttributes("image")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("image").width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</BaseBubbleMenu>
);

View File

@ -1,4 +1,4 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react";
import { BubbleMenu as BaseBubbleMenu, useEditorState } from "@tiptap/react";
import React, { useCallback, useState } from "react";
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
@ -12,7 +12,18 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
return editor.isActive("link");
}, [editor]);
const { href: link } = editor.getAttributes("link");
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const link = ctx.editor.getAttributes("link");
return {
href: link.href,
};
},
});
const handleEdit = useCallback(() => {
setShowEdit(true);
@ -70,11 +81,14 @@ export function LinkMenu({ editor, appendTo }: EditorMenuProps) {
padding="xs"
bg="var(--mantine-color-body)"
>
<LinkEditorPanel initialUrl={link} onSetLink={onSetLink} />
<LinkEditorPanel
initialUrl={editorState?.href}
onSetLink={onSetLink}
/>
</Card>
) : (
<LinkPreviewPanel
url={link}
url={editorState?.href}
onClear={onUnsetLink}
onEdit={handleEdit}
/>

View File

@ -17,8 +17,10 @@ import {
IconTable,
IconTypography,
IconMenu4,
IconCalendar, IconAppWindow,
} from '@tabler/icons-react';
IconCalendar,
IconAppWindow,
IconSitemap,
} from "@tabler/icons-react";
import {
CommandProps,
SlashMenuGroupedItemsType,
@ -357,6 +359,15 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
{
title: "Subpages (Child pages)",
description: "List all subpages of the current page",
searchTerms: ["subpages", "child", "children", "nested", "hierarchy"],
icon: IconSitemap,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).insertSubpages().run();
},
},
{
title: "Iframe embed",
description: "Embed any Iframe",

View File

@ -0,0 +1,95 @@
import {
BubbleMenu as BaseBubbleMenu,
posToDOMRect,
findParentNode,
} from "@tiptap/react";
import { Node as PMNode } from "@tiptap/pm/model";
import React, { useCallback } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Editor } from "@tiptap/core";
import { sticky } from "tippy.js";
interface SubpagesMenuProps {
editor: Editor;
}
interface ShouldShowProps {
state: any;
from?: number;
to?: number;
}
export const SubpagesMenu = React.memo(
({ editor }: SubpagesMenuProps): JSX.Element => {
const { t } = useTranslation();
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("subpages");
},
[editor],
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "subpages";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const deleteNode = useCallback(() => {
const { selection } = editor.state;
editor
.chain()
.focus()
.setNodeSelection(selection.from)
.deleteSelection()
.run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`subpages-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: "flip", enabled: false }],
},
plugins: [sticky],
sticky: "popper",
}}
shouldShow={shouldShow}
>
<Tooltip position="top" label={t("Delete")}>
<ActionIcon
onClick={deleteNode}
variant="default"
size="lg"
color="red"
aria-label={t("Delete")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</BaseBubbleMenu>
);
},
);
export default SubpagesMenu;

View File

@ -0,0 +1,120 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import classes from "./subpages.module.css";
import styles from "../mention/mention.module.css";
import {
buildPageUrl,
buildSharedPageUrl,
} from "@/features/page/page.utils.ts";
import { useTranslation } from "react-i18next";
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
export default function SubpagesView(props: NodeViewProps) {
const { editor } = props;
const { spaceSlug, shareId } = useParams();
const { t } = useTranslation();
const currentPageId = editor.storage.pageId;
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({
pageId: currentPageId,
});
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
if (shareId && sharedSubpages) {
return sharedSubpages.map((node) => ({
id: node.value,
slugId: node.slugId,
title: node.name,
icon: node.icon,
position: node.position,
}));
}
// Otherwise use the API data
if (!data?.pages) return [];
const allPages = data.pages.flatMap((page) => page.items);
return sortPositionKeys(allPages);
}, [data, shareId, sharedSubpages]);
if (isLoading && !shareId) {
return null;
}
if (error && !shareId) {
return (
<NodeViewWrapper>
<Text c="dimmed" size="md" py="md">
{t("Failed to load subpages")}
</Text>
</NodeViewWrapper>
);
}
if (subpages.length === 0) {
return (
<NodeViewWrapper>
<div className={classes.container}>
<Text c="dimmed" size="md" py="md">
{t("No subpages")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper>
<div className={classes.container}>
<Stack gap={5}>
{subpages.map((page) => (
<Anchor
key={page.id}
component={Link}
fw={500}
to={
shareId
? buildSharedPageUrl({
shareId,
pageSlugId: page.slugId,
pageTitle: page.title,
})
: buildPageUrl(spaceSlug, page.slugId, page.title)
}
underline="never"
className={styles.pageMentionLink}
draggable={false}
>
{page?.icon ? (
<span style={{ marginRight: "4px" }}>{page.icon}</span>
) : (
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
)}
<span className={styles.pageMentionText}>
{page?.title || t("untitled")}
</span>
</Anchor>
))}
</Stack>
</div>
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,9 @@
.container {
margin: 0;
padding-left: 4px;
user-select: none;
a {
border: none !important;
}
}

View File

@ -9,7 +9,8 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface TableColorItem {
@ -18,7 +19,7 @@ export interface TableColorItem {
}
interface TableBackgroundColorProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
}
const TABLE_COLORS: TableColorItem[] = [
@ -38,37 +39,50 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
let currentColor = "";
if (ctx.editor.isActive("tableCell")) {
const attrs = ctx.editor.getAttributes("tableCell");
currentColor = attrs.backgroundColor || "";
} else if (ctx.editor.isActive("tableHeader")) {
const attrs = ctx.editor.getAttributes("tableHeader");
currentColor = attrs.backgroundColor || "";
}
return {
currentColor,
isTableCell: ctx.editor.isActive("tableCell"),
isTableHeader: ctx.editor.isActive("tableHeader"),
};
},
});
if (!editor || !editorState) {
return null;
}
const setTableCellBackground = (color: string, colorName: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
backgroundColorName: color ? colorName : null,
})
.updateAttributes("tableHeader", {
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
backgroundColorName: color ? colorName : null,
})
.run();
setOpened(false);
};
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return (
<Popover
width={200}
@ -123,7 +137,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
cursor: "pointer",
}}
>
{currentColor === item.color && (
{editorState.currentColor === item.color && (
<IconCheck
size={18}
style={{

View File

@ -9,15 +9,15 @@ import {
ActionIcon,
Button,
Popover,
rem,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps {
editor: ReturnType<typeof useEditor>;
editor: Editor | null;
}
interface AlignmentItem {
@ -32,25 +32,44 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
return {
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }),
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }),
isAlignRight: ctx.editor.isActive({ textAlign: "right" }),
};
},
});
if (!editor || !editorState) {
return null;
}
const items: AlignmentItem[] = [
{
name: "Align left",
value: "left",
isActive: () => editor.isActive({ textAlign: "left" }),
isActive: () => editorState?.isAlignLeft,
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
value: "center",
isActive: () => editor.isActive({ textAlign: "center" }),
isActive: () => editorState?.isAlignCenter,
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
value: "right",
isActive: () => editor.isActive({ textAlign: "right" }),
isActive: () => editorState?.isAlignRight,
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
@ -64,7 +83,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
onChange={setOpened}
position="bottom"
withArrow
transitionProps={{ transition: 'pop' }}
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
@ -87,9 +106,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
key={index}
variant="default"
leftSection={<item.icon size={16} />}
rightSection={
item.isActive() && <IconCheck size={16} />
}
rightSection={item.isActive() && <IconCheck size={16} />}
justify="left"
fullWidth
onClick={() => {
@ -106,4 +123,4 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
</Popover.Dropdown>
</Popover>
);
};
};

View File

@ -2,6 +2,7 @@ import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
useEditorState,
} from "@tiptap/react";
import React, { useCallback } from "react";
import { sticky } from "tippy.js";
@ -32,6 +33,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
[editor],
);
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const videoAttrs = ctx.editor.getAttributes("video");
return {
isVideo: ctx.editor.isActive("video"),
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
};
},
});
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "video";
@ -83,7 +103,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`video-menu}`}
pluginKey={`video-menu`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
@ -103,9 +123,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoLeft}
size="lg"
aria-label={t("Align left")}
variant={
editor.isActive("video", { align: "left" }) ? "light" : "default"
}
variant={editorState?.isAlignLeft ? "light" : "default"}
>
<IconLayoutAlignLeft size={18} />
</ActionIcon>
@ -116,11 +134,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoCenter}
size="lg"
aria-label={t("Align center")}
variant={
editor.isActive("video", { align: "center" })
? "light"
: "default"
}
variant={editorState?.isAlignCenter ? "light" : "default"}
>
<IconLayoutAlignCenter size={18} />
</ActionIcon>
@ -131,20 +145,15 @@ export function VideoMenu({ editor }: EditorMenuProps) {
onClick={alignVideoRight}
size="lg"
aria-label={t("Align right")}
variant={
editor.isActive("video", { align: "right" }) ? "light" : "default"
}
variant={editorState?.isAlignRight ? "light" : "default"}
>
<IconLayoutAlignRight size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
{editor.getAttributes("video")?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes("video").width)}
/>
{editorState?.width && (
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
)}
</BaseBubbleMenu>
);

View File

@ -38,6 +38,8 @@ import {
Embed,
SearchAndReplace,
Mention,
Subpages,
TableDndExtension,
} from "@docmost/editor-ext";
import {
randomElement,
@ -57,6 +59,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import abap from "highlightjs-sap-abap";
@ -162,12 +165,13 @@ export const mainExtensions = [
}),
CustomTable.configure({
resizable: true,
lastColumnResizable: false,
lastColumnResizable: true,
allowTableNodeSelection: true,
}),
TableRow,
TableCell,
TableHeader,
TableDndExtension,
MathInline.configure({
view: MathInlineView,
}),
@ -212,6 +216,9 @@ export const mainExtensions = [
Embed.configure({
view: EmbedView,
}),
Subpages.configure({
view: SubpagesView,
}),
MarkdownClipboard.configure({
transformPastedText: true,
}),

View File

@ -7,7 +7,12 @@ import {
onAuthenticationFailedParameters,
WebSocketStatus,
} from "@hocuspocus/provider";
import { EditorContent, EditorProvider, useEditor } from "@tiptap/react";
import {
EditorContent,
EditorProvider,
useEditor,
useEditorState,
} from "@tiptap/react";
import {
collabExtensions,
mainExtensions,
@ -31,6 +36,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import {
handleFileDrop,
handlePaste,
@ -49,6 +55,7 @@ import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
interface PageEditorProps {
pageId: string;
@ -75,7 +82,7 @@ export default function PageEditor({
const [isLocalSynced, setLocalSynced] = useState(false);
const [isRemoteSynced, setRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const documentName = `page.${pageId}`;
@ -211,16 +218,20 @@ export default function PageEditor({
extensions,
editable,
immediatelyRender: true,
shouldRerenderOnTransaction: true,
shouldRerenderOnTransaction: false,
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
if ((event.ctrlKey || event.metaKey) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if ((event.ctrlKey || event.metaKey) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
@ -262,9 +273,16 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
[pageId, editable, remoteProvider]
[pageId, editable, remoteProvider],
);
const editorIsEditable = useEditorState({
editor,
selector: (ctx) => {
return ctx.editor?.isEditable ?? false;
},
});
const debouncedUpdateContent = useDebouncedCallback((newContent: any) => {
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
@ -300,7 +318,7 @@ export default function PageEditor({
return () => {
document.removeEventListener(
"ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent
handleActiveCommentEvent,
);
};
}, []);
@ -375,7 +393,7 @@ export default function PageEditor({
}
return (
<div style={{ position: "relative" }}>
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
<EditorContent editor={editor} />
@ -383,7 +401,7 @@ export default function PageEditor({
<SearchAndReplaceDialog editor={editor} editable={editable} />
)}
{editor && editor.isEditable && (
{editor && editorIsEditable && (
<div>
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
@ -391,6 +409,7 @@ export default function PageEditor({
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />

View File

@ -6,21 +6,19 @@ import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
import { Text } from "@tiptap/extension-text";
import { Placeholder } from "@tiptap/extension-placeholder";
import { useAtom } from "jotai/index";
import {
pageEditorAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { Editor } from "@tiptap/core";
import { useAtom } from "jotai";
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
interface PageEditorProps {
title: string;
content: any;
pageId?: string;
}
export default function ReadonlyPageEditor({
title,
content,
pageId,
}: PageEditorProps) {
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
@ -56,6 +54,9 @@ export default function ReadonlyPageEditor({
content={content}
onCreate={({ editor }) => {
if (editor) {
if (pageId) {
editor.storage.pageId = pageId;
}
// @ts-ignore
setReadOnlyEditor(editor);
}

View File

@ -33,8 +33,8 @@
}
p {
margin-top: 0.65em;
margin-bottom: 0.65em;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
ul,
@ -57,6 +57,7 @@
h5,
h6 {
line-height: 1.1;
margin-bottom: 10px;
}
code {
@ -93,8 +94,12 @@
hr {
border: none;
border-top: 2px solid #ced4da;
margin: 2rem 0;
@mixin light {
border-top: 1px solid var(--mantine-color-gray-4);
}
@mixin dark {
border-top: 1px solid var(--mantine-color-dark-4);
}
&:hover {
cursor: pointer;
@ -136,7 +141,7 @@
.selection,
*::selection {
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-gray-7));
background-color: Highlight;
}
.comment-mark {

View File

@ -45,6 +45,10 @@
display: none;
pointer-events: none;
}
&[data-direction='horizontal'] {
rotate: 90deg;
}
}
.dark .drag-handle {

View File

@ -1,11 +1,23 @@
@media print {
.mantine-AppShell-header,
.mantine-AppShell-navbar,
.mantine-AppShell-aside{
display: none !important;
}
.mantine-AppShell-header,
.mantine-AppShell-navbar,
.mantine-AppShell-aside,
.print-hide,
.drag-handle {
display: none !important;
}
.mantine-AppShell-main {
padding-top: 0 !important;
}
.mantine-AppShell-main {
padding-top: 0 !important;
min-height: auto !important;
}
.ProseMirror-selectednode {
outline: none !important;
box-shadow: none !important;
}
.tableWrapper {
overflow: hidden !important;
}
}

View File

@ -8,6 +8,16 @@
}
}
.table-dnd-preview {
padding: 0;
background-color: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(2px);
}
.table-dnd-drop-indicator {
background-color: #adf;
}
.ProseMirror {
table {
border-collapse: collapse;
@ -118,3 +128,13 @@
}
}
}
.editor-container:has(.table-dnd-drop-indicator[data-dragging="true"]) {
.prosemirror-dropcursor-block {
display: none;
}
.prosemirror-dropcursor-inline {
display: none;
}
}

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