Compare commits

...

156 Commits

Author SHA1 Message Date
9a16780d9f custom tier 2025-06-24 04:42:09 -07:00
5fb90527ad feat: tiered billing (cloud) 2025-06-24 04:21:38 -07:00
65b01038d7 v0.21.0 2025-06-18 14:28:14 -07:00
e07cb57b01 sync 2025-06-18 14:25:40 -07:00
2b53e0a455 fix: add import size limit to static window config 2025-06-18 13:58:41 -07:00
b9b3406b28 Fix: Prevent premature focus change in TitleEditor when pressing Enter during IME composition (#730)
* fix: Prevents key events during text composition

Stops handling title key events when composing text,
ensuring proper input behavior during IME use.

* Refines IME composition event checks

Separates IME composition control from shift key logic and adds a Safari-specific keyCode check to prevent premature focus shifts during IME input.
2025-06-18 21:33:35 +01:00
728cac0a34 fix word counter (#1269) 2025-06-18 21:32:11 +01:00
d35e16010b handle empty invitation 2025-06-18 13:10:32 -07:00
15791d4e59 sync 2025-06-18 12:50:43 -07:00
3318e13225 fix: use JWT expiry time for cookie duration (#1268)
* Set default jwt expiry to 90 days.
2025-06-18 20:50:11 +01:00
080900610d cleanup 2025-06-17 16:14:06 -07:00
d1dc6977ab feat: edit mode preference (#666)
* lock/unlock pages

* remove using isLocked column - add default page edit state preference

* * Move state management to editors (avoids flickers on edit mode switch)
* Rename variables
* Add strings to translation file
* Memoize components in page component
* Fix title editor sending update request on editable state change

* fixed errors merging main

* Fix embed view in read-only mode

* remove unused line

* sync

* fix responsiveness on mobile

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-06-18 00:11:47 +01:00
5f62448894 less create workspace form fields in cloud (#1265)
* sync

* less signup form fields in cloud

* min length
2025-06-17 23:56:07 +01:00
44445fbf46 fix: enforce SSO in invitation signups (#1258) 2025-06-15 20:25:15 +01:00
1c674efddd fix: revert tiptap version (#1255) 2025-06-13 21:38:49 +01:00
ccf7e34e99 feat: ukrainian language support (#1250) 2025-06-11 23:31:45 +01:00
f39d48d6ee New Crowdin updates (#1063)
* New translations translation.json
2025-06-11 23:21:01 +01:00
f584ea84b0 chore: upgrade packages (#1242)
* upgrade tiptap editor extensions

* upgrade packages

* fix type issue
2025-06-11 23:18:39 +01:00
bc0c4d6258 fix: make link popup work on safari (#1243)
* fix: make link popup work on safari

* fix: second iteration

* chore: cleanup

* chore: format

* chore: undo unused stuff
2025-06-11 23:09:59 +01:00
d8da307a61 feat: enhance excalidraw (#1240)
* WIP

* use next excalidraw version

* support local persistence for excalidraw library.

Co-authored-by: Drauggy <n.fomenko@safe-tech.ru>

---------

Co-authored-by: Drauggy <n.fomenko@safe-tech.ru>
2025-06-09 23:25:36 +01:00
50b3f9ddd9 generic iframe embed (#1234) 2025-06-09 22:32:23 +01:00
0029f84d50 feat: toggle table header row and column (#1203)
* feat: toggle table header row and column
* switch position
2025-06-09 05:39:43 +01:00
6d024fc3de feat: bulk page imports (#1219)
* refactor imports - WIP

* Add readstream

* WIP

* fix attachmentId render

* fix attachmentId render

* turndown video tag

* feat: add stream upload support and improve file handling

- Add stream upload functionality to storage drivers\n- Improve ZIP file extraction with better encoding handling\n- Fix attachment ID rendering issues\n- Add AWS S3 upload stream support\n- Update dependencies for better compatibility

* WIP

* notion formatter

* move embed parser to editor-ext package

* import embeds

* utility files

* cleanup

* Switch from happy-dom to cheerio
* Refine code

* WIP

* bug fixes and UI

* sync

* WIP

* sync

* keep import modal mounted

* Show modal during upload

* WIP

* WIP
2025-06-09 04:29:27 +01:00
ce1503af85 fix: sidebar list when changing workspace (#1150)
* init

* navigate in overview if current page is in deleted node

* fix: implement pagination in sidebar-pages queries

* fix: appendNodeChildren()

Preserve deeper children if they exist and remove node if deleted
2025-06-08 03:27:09 +01:00
69447fc375 Merge branch 'main' of https://github.com/docmost/docmost 2025-05-21 08:43:56 -07:00
858ff9da06 sync 2025-05-20 09:27:30 -07:00
343b2976c2 #1186/chore: add support language abap syntax highlight (#1188) 2025-05-19 20:05:31 +01:00
7491224d0f hide shared page branding in EE (#1193)
* hide shared page branding in EE

* Hide branding in business plan
2025-05-17 19:17:34 +01:00
4a0b4040ed Add second plan (#1187) 2025-05-17 19:03:01 +01:00
e3ba817723 feat: comment editor emoji picker and ctrl+enter action (#1121)
* commenteditor-emoji-picker

* capture Mac command key
* remove tooltip

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-05-16 20:01:27 +01:00
b0491d5da4 feat: create new page from mention (#1153)
* init

* create page in relative parent root
2025-05-16 19:15:11 +01:00
1c200dbd0f fix(table-hover): adjust row height to prevent unexpected scrollbar on hover (#1124)
fix: Hover table style height error causing scrollbar to appear #1108
2025-05-16 16:26:05 +01:00
fb7e4a7956 fix: copy/move select (#1174) 2025-05-16 16:24:31 +01:00
1413033568 feat: realtime comments (#1144)
* init

* fix: close bubblemenu after comment and wait before scroll

* scroll to comment when click

* highlight comment animation
2025-05-16 16:18:23 +01:00
00f4588c21 fix title update (#1154) 2025-05-16 16:11:29 +01:00
3a75251e75 fix alignment in shared page (#1123) 2025-05-16 16:00:47 +01:00
c6bca6a602 fix deprecated kysely usage 2025-05-09 16:44:33 +01:00
55d1a2c932 Fix typo in enforce-sso.tsx (#1145) 2025-05-09 11:11:02 +01:00
bc3cb2d63f fix: increase random subdomain suffix 2025-05-07 15:10:58 +01:00
7adbf85030 v0.20.4 2025-04-30 14:44:58 +01:00
de7982fe30 feat: copy page to different space (#1118)
* Add copy page to space endpoint
* copy storage function
* copy function
* feat: copy attachments too
* Copy page - WIP
* fix type
* sync
* cleanup
2025-04-30 14:43:16 +01:00
0402f7efb5 sync 2025-04-30 14:33:01 +01:00
8327251ab6 fix typo 2025-04-29 23:30:12 +01:00
e8847bd9cd fix: handle unhandled exceptions (#1116)
* Handle unhandled exceptions
* cleanup
2025-04-29 23:29:00 +01:00
9bbd62e0f0 v0.20.3 2025-04-24 23:22:53 +01:00
0289c5cb09 Reduce markdown checkbox space 2025-04-24 23:19:39 +01:00
7993532111 fix page export (#1081) 2025-04-24 23:18:54 +01:00
31e5c0c660 v0.20.2 2025-04-24 17:57:14 +01:00
33c314d4e8 remove clickoutside hook 2025-04-24 17:56:54 +01:00
08f223899a cloud trial refactor 2025-04-23 16:07:58 +01:00
c528f7e858 v0.20.1 2025-04-23 14:34:28 +01:00
c26a851d52 feat: enhance public sharing (#1057)
* fix tree nodes sort

* remove comment mark in shares

* remove clickoutside hook for now

* feat: search in shared pages

* fix user-select

* use Link

* render page icons
2025-04-23 14:32:35 +01:00
de5f90309c v0.20.0 2025-04-22 22:49:45 +01:00
0ec3ff2965 Add empty placeholder text 2025-04-22 22:48:12 +01:00
acffeacdbc fix TOC 2025-04-22 22:47:34 +01:00
00d92a3690 New Crowdin updates (#1008)
* New translations translation.json (Russian)

* New translations translation.json (Russian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (Spanish)

* 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 (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2025-04-22 20:57:07 +01:00
3430f715ec feat: remember and restore previous route when exiting settings (#1046)
Improves user experience by allowing users to return to the previous
page after visiting the Settings section.

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-04-22 20:47:57 +01:00
6c422011ac feat: public page sharing (#1012)
* Share - WIP

* - public attachment links
- WIP

* WIP

* WIP

* Share - WIP

* WIP

* WIP

* include userRole in space object

* WIP

* Server render shared page meta tags

* disable user select

* Close Navbar on outside click on mobile

* update shared page spaceId

* WIP

* fix

* close sidebar on click

* close sidebar

* defaults

* update copy

* Store share key in lowercase

* refactor page breadcrumbs

* Change copy

* add link ref

* open link button

* add meta og:title

* add twitter tags

* WIP

* make shares/info endpoint public

* fix

* * add /p/ segment to share urls
* minore fixes

* change mobile breadcrumb icon
2025-04-22 20:37:32 +01:00
3e8824435d update vite and axios 2025-04-22 20:28:27 +01:00
37a1804db9 Revert "switch to vite rolldown (#1048)" (#1050)
This reverts commit 1a1b2c8682.
2025-04-22 20:00:36 +01:00
882f3093bd search space members by email (#1049) 2025-04-22 19:37:06 +01:00
1a1b2c8682 switch to vite rolldown (#1048)
* switch to vite rolldown

* update
2025-04-22 15:52:44 +01:00
10b67929ea Update README.md 2025-04-21 21:50:21 +01:00
5c957fda8d fix: nested tree open state 2025-04-21 19:24:25 +01:00
862f6d4820 use non-esm nanoid version (#1040) 2025-04-19 19:45:09 +01:00
de57d05199 0.10.2 2025-04-15 12:48:40 +01:00
89ec990232 sync ee 2025-04-15 12:46:28 +01:00
49d0f1cc9a Add click handler 2025-04-11 13:41:43 +01:00
268001ae26 v0.10.1 2025-04-11 13:23:42 +01:00
27fa45a769 fix local attachment paths in exports (#1013) 2025-04-11 13:18:44 +01:00
f9711918a3 fix comment editor padding 2025-04-11 12:32:54 +01:00
29bb52db0c v0.10.0 2025-04-09 19:14:51 +01:00
f2241db5ee remove beta message 2025-04-09 19:14:33 +01:00
58d1855a36 fix hash check 2025-04-09 19:03:27 +01:00
7fe3c5f177 * time ago hook 2025-04-09 18:47:39 +01:00
5fd477d074 collapse by default in node-edit mode 2025-04-09 15:46:29 +01:00
4aa5d7e326 hide history action menu for can-view role (#1001) 2025-04-09 15:42:29 +01:00
7f7f2bccd0 fix toggle node in non-edit mode 2025-04-09 15:37:18 +01:00
a9f370660b New Crowdin updates (#1005)
* 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 (Chinese Simplified)

* 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 (Chinese Simplified)

* New translations translation.json (Portuguese, Brazilian)
2025-04-08 17:28:33 +01:00
117c7049ff fix 2025-04-08 17:15:09 +01:00
cd10365f71 new translations 2025-04-08 17:10:48 +01:00
ee30d9d0f2 New Crowdin updates (#1003)
* New translations translation.json (French)

* New translations translation.json (Italian)

* New translations translation.json (Japanese)

* New translations translation.json (Korean)

* New translations translation.json (Russian)

* New translations translation.json (Chinese Simplified)

* New translations translation.json (English)

* New translations translation.json (Portuguese, Brazilian)
2025-04-08 17:10:08 +01:00
276ececbf2 cleanup 2025-04-08 17:06:32 +01:00
fa194a497c cleanup 2025-04-08 17:04:43 +01:00
1eaba6e77f fix: bug fixes (#1000)
* sort by groups first

* add scroll area

* fix group members pagination

* move pagination to the right
2025-04-08 13:34:00 +01:00
651e5f6153 null check 2025-04-08 11:59:47 +01:00
7431804a46 feat: delete workspace member (#987)
* add delete user endpoint (server)

* delete user (UI)

* prevent token generation

* more checks
2025-04-07 19:26:03 +01:00
3559358d14 fix pagination issue where user is not part of any space 2025-04-07 19:09:02 +01:00
06270ff747 - fixes
- allow mail from address override
- queue cloud emails
2025-04-07 19:07:10 +01:00
233536314f feat: add Table of contents (#981)
* chore: add table of contents module

* refactor

* lint

* null check

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-04-05 19:03:42 +01:00
17ce3bab8a feat: move page between spaces (#988)
* feat: Move the page to another space

- The ability to move a page to another space has been added

* feat: Move the page to another space
* feat: Move the page to another space

- Correction of the visibility attribute of elements that extend beyond the boundaries of the space selection modal window

* feat: Move the page to another space

- Added removal of query keys when moving pages

* feat: Move the page to another space

- Fix locales

* feat: Move the page to another space
* feat: Move the page to another space

- Fix docker compose

* feat: Move the page to another space

* feat: Move the page to another space

- Some refactor

* feat: Move the page to another space

- Attachments update

* feat: Move the page to another space

- The function of searching for attachments by page ID and updating attachments has been combined

* feat: Move the page to another space

- Fix variable name

* feat: Move the page to another space

- Move current space to parameter of component SpaceSelectionModal

* refactor ui

---------

Co-authored-by: plekhanov <astecom@mail.ru>
2025-04-04 23:44:18 +01:00
b27d1708b0 queue trial ended job (#992) 2025-04-04 23:35:08 +01:00
64f0531093 feat: keep track of page contributors (#959)
* WIP

* feat: store and retrieve page contributors
2025-04-04 13:03:57 +01:00
8aa604637e feat: nested toggle block (#671)
* feat: nested toggle block

* fix: md export

* fix detailsButton icon alignment

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-04-04 13:01:39 +01:00
7ca2b437d4 sync 2025-04-03 14:08:06 +01:00
595bd1dc81 Fix editor connection loop (#986)
* fix editor connection loop

* remove query refresh
2025-04-03 14:05:34 +01:00
a74d3feae4 fix: make collab ready reliable on tab return 2025-03-27 14:39:43 +00:00
e40faf97ec v0.9.0 2025-03-23 14:07:30 +00:00
bbe4fe99f9 don't replace line breaks 2025-03-23 13:57:05 +00:00
8300c5b731 update env file 2025-03-23 13:14:20 +00:00
13039cfacc telemetry module (#934)
* update lockfile

* fix color check

* telemetry

* complete

* Use interval
2025-03-23 13:12:41 +00:00
593f41a050 adds missing command for down migration (#908) 2025-03-22 15:30:37 +00:00
f8ce160906 feat: add version check (#922)
* Add version endpoint

* version indicator

* refetch

* * Translate strings
* Handle error
2025-03-22 15:29:10 +00:00
c824b5b570 fix collab token refresh which leads to collab editor reconnection loop (#933) 2025-03-22 15:15:50 +00:00
37e760d76c * fix color check
* update lock file
2025-03-22 12:31:01 +00:00
442fa23399 Refetch space list on mount 2025-03-17 11:49:42 +00:00
2e5990d057 Move suspense above popover dropdown 2025-03-17 11:23:57 +00:00
15bdbf74cd null check 2025-03-17 11:23:18 +00:00
3d9a7d808b Revert "feat: auto focus emoji-picker search when opened (#894)" (#900)
This reverts commit 573457403e.
2025-03-17 11:17:44 +00:00
f45bdddb23 feat: billing sync (cloud) (#899)
* Set page history to 5 minutes interval

* * Configure default queue options

* sync

* * stripe seats sync (cloud)
2025-03-17 11:00:23 +00:00
21c3ad0ecc feat: enhance editor uploads (#895)
* * multi-file paste support
* allow media files (image/videos) to be attachments
* insert trailing node if file placeholder is at the end of the editor

* fix video align
2025-03-15 18:27:26 +00:00
573457403e feat: auto focus emoji-picker search when opened (#894)
Co-authored-by: JonasRingeis <jonas.ringeis@otto.de>
2025-03-15 18:25:01 +00:00
d021d0a38f fix 2025-03-14 23:02:42 +00:00
96dfe9f817 fix: page title editor bugs (#892)
* Fix page title

* compare empty page title

* Properly handle null tree node name and icon
2025-03-14 22:41:34 +00:00
598361992e fix trial days 2025-03-14 22:40:35 +00:00
210d1474ea Add Dutch translation (#877) 2025-03-13 15:26:23 +00:00
5f520689ed prevent overflow 2025-03-13 15:23:35 +00:00
2a535de29d New Crowdin updates (#840) 2025-03-13 15:10:28 +00:00
f45d9dc5a0 feat: add page stats to page menu (#876) 2025-03-13 14:54:18 +00:00
f7a14e23cd fix editor flickers (#875) 2025-03-13 08:58:21 +00:00
1f40e9b960 fix drag handle visibility (#868) 2025-03-12 13:17:59 +00:00
fea6518352 fix: VSCode markdown pasting (#857)
* fix vscode markdown pasting

* fix markdown -> html formatting
2025-03-10 02:38:22 +00:00
061a02ce51 Make codeblock comment more legible in light mode 2025-03-10 02:15:15 +00:00
2205ce0c3b prevent slider flickers 2025-03-10 01:15:21 +00:00
a812cdcf15 enable shouldRerenderOnTransaction 2025-03-09 22:49:58 +00:00
30acc6676a exclude billing webhook endpoint 2025-03-08 19:08:02 +00:00
5c9e0a2630 * prefetch sso providers in settings
* hide sso enforcement in standard plan
2025-03-08 18:26:34 +00:00
fd36076ae7 feat: disconnect collab websocket on idle tabs (#848)
* disconnect real-time collab if user is idle
* log yjs document disconnect and unload in dev mode
* no longer set editor to read-only mode on collab websocket disconnection
* treat delayed collab websocket "connecting" state as disconnected
* increase maxDebounce to 45 seconds
* add reset handle to useIdle hook
2025-03-08 18:16:23 +00:00
dd52eb15ca fix: table header in exported markdown (#769) 2025-03-07 12:16:49 +00:00
6776e073b6 feat: adding family 6 in uri to configure for both 4 and 6 (#807)
* feat: adding family 6 in uri to configure for both 4 and 6
* feat: adding redis family in websocket config
2025-03-07 12:12:19 +00:00
7a47da9273 Add emoji command to title editor 2025-03-07 11:57:28 +00:00
e62bc6c250 feat: editor emoji picker (#775)
* feat: emoji picker

* fix: lazy load emoji data

* loading animation (for slow connection)

* parsing :shortcode: and replace with emoji + add extension to title-editor

* fix

* Remove title editor support
* Remove shortcuts support
* Cleanup

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-03-07 11:53:06 +00:00
4f9e588494 sort workspace list 2025-03-07 11:51:04 +00:00
05a3dfa26d Option to log db queries in dev mode (#827) 2025-03-07 00:06:25 +00:00
8826cca539 fix space translations (#826) 2025-03-07 00:03:57 +00:00
1988feb9ce exclude /health/live endpoint 2025-03-06 23:45:41 +00:00
e9b7273489 remove cloud env check 2025-03-06 22:30:24 +00:00
315afd6818 fix cookie name 2025-03-06 21:44:53 +00:00
93ea31feb0 sync 2025-03-06 21:09:05 +00:00
3b4e414c97 * configurable trial days
* hide create sso provider in cloud
2025-03-06 21:06:24 +00:00
d925c95fc9 add pnpm to packageManager for consistency 2025-03-06 18:54:33 +00:00
4511db1526 fix 2025-03-06 18:32:25 +00:00
56d9e46fd3 * Upgrade Dockerfile to node 22
* Pin pnpm to pnpm@10.4.0
2025-03-06 18:29:15 +00:00
cdea149ce7 * Update EE license fil
* State license in Readme file
2025-03-06 17:59:22 +00:00
16254802e3 Add api prefix to attachment nodes 2025-03-06 14:19:29 +00:00
a7dd9b9198 Hide version in cloud 2025-03-06 14:17:20 +00:00
b81c9ee10c feat: cloud and ee (#805)
* stripe init
git submodules for enterprise modules

* * Cloud billing UI - WIP
* Proxy websockets in dev mode
* Separate workspace login and creation for cloud
* Other fixes

* feat: billing (cloud)

* * add domain service
* prepare links from workspace hostname

* WIP

* Add exchange token generation
* Validate JWT token type during verification

* domain service

* add SkipTransform decorator

* * updates (server)
* add new packages
* new sso migration file

* WIP

* Fix hostname generation

* WIP

* WIP

* Reduce input error font-size
* set max password length

* jwt package

* license page - WIP

* * License management UI
* Move license key store to db

* add reflector

* SSO enforcement

* * Add default plan
* Add usePlan hook

* * Fix auth container margin in mobile
* Redirect login and home to select page in cloud

* update .gitignore

* Default to yearly

* * Trial messaging
* Handle ended trials

* Don't set to readonly on collab disconnect (Cloud)

* Refine trial (UI)
* Fix bug caused by using jotai optics atom in AppHeader component

* configurable database maximum pool

* Close SSO form on save

* wip

* sync

* Only show sign-in in cloud

* exclude base api part from workspaceId check

* close db connection beforeApplicationShutdown

* Add health/live endpoint

* clear cookie on hostname change

* reset currentUser atom

* Change text

* return 401 if workspace does not match

* feat: show user workspace list in cloud login page

* sync

* Add home path

* Prefetch to speed up queries

* * Add robots.txt
* Disallow login and forgot password routes

* wildcard user-agent

* Fix space query cache

* fix

* fix

* use space uuid for recent pages

* prefetch billing plans

* enhance license page

* sync
2025-03-06 13:38:37 +00:00
91596be70e fix: add missing awaits (#814) 2025-03-06 10:14:30 +00:00
72f64e7b10 revert sentry (#808)
* revert sentry
* remove sentry env
2025-02-27 15:58:32 +00:00
3cfb17bb62 fix sentry 2025-02-27 14:44:28 +00:00
fe5066c7b5 v0.8.4 2025-02-27 14:34:38 +00:00
e13be904cd cleanup 2025-02-27 14:18:25 +00:00
fda5c7d60f push files left (#360) (#804) 2025-02-26 18:33:50 +00:00
7fc1a782a7 feat: add copy invite link to invitation action menu (#360)
* +copy invite link to clipboard from invite action menu

* -remove log to console for copy link action

* Refactor copy invite link feature

---------

Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
2025-02-26 18:28:44 +00:00
54d27af76a * Add SENTRY_DNS env variable
* Commit lock file
2025-02-26 17:38:25 +00:00
0065f29634 feat: sentry (#802) 2025-02-26 15:42:19 +00:00
374 changed files with 22072 additions and 5236 deletions

View File

@ -41,4 +41,6 @@ SMTP_IGNORETLS=false
POSTMARK_TOKEN=
# for custom drawio server
DRAWIO_URL=
DRAWIO_URL=
DISABLE_TELEMETRY=false

2
.gitignore vendored
View File

@ -1,4 +1,6 @@
.env
.env.dev
.env.prod
data
# compiled output
/dist

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "apps/server/src/ee"]
path = apps/server/src/ee
url = https://github.com/docmost/ee

View File

@ -1,4 +1,4 @@
FROM node:21-alpine AS base
FROM node:22-alpine AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
FROM base AS builder
@ -7,7 +7,7 @@ WORKDIR /app
COPY . .
RUN npm install -g pnpm
RUN npm install -g pnpm@10.4.0
RUN pnpm install --frozen-lockfile
RUN pnpm build
@ -33,7 +33,7 @@ COPY --from=builder /app/pnpm*.yaml /app/
# Copy patches
COPY --from=builder /app/patches /app/patches
RUN npm install -g pnpm
RUN npm install -g pnpm@10.4.0
RUN chown -R node:node /app

View File

@ -4,18 +4,18 @@
Open-source collaborative wiki and documentation software.
<br />
<a href="https://docmost.com"><strong>Website</strong></a> |
<a href="https://docmost.com/docs"><strong>Documentation</strong></a>
<a href="https://docmost.com/docs"><strong>Documentation</strong></a> |
<a href="https://twitter.com/DocmostHQ"><strong>Twitter / X</strong></a>
</p>
</div>
<br />
> [!NOTE]
> Docmost is currently in **beta**. We value your feedback as we progress towards a stable release.
## Getting started
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) .
## Features
- Real-time collaboration
- Diagrams (Draw.io, Excalidraw and Mermaid)
- Spaces
@ -24,13 +24,39 @@ To get started with Docmost, please refer to our [documentation](https://docmost
- Comments
- Page history
- Search
- File attachment
- File attachments
- Embeds (Airtable, Loom, Miro and more)
- Translations (10+ languages)
### Screenshots
#### Screenshots
<p align="center">
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
</p>
### Contributing
### License
Docmost core is licensed under the open-source AGPL 3.0 license.
Enterprise features are available under an enterprise license (Enterprise Edition).
All files in the following directories are licensed under the Docmost Enterprise license defined in `packages/ee/License`.
- apps/server/src/ee
- apps/client/src/ee
- packages/ee
### Contributing
See the [development documentation](https://docmost.com/docs/self-hosting/development)
## Thanks
Special thanks to;
<img width="100" alt="Crowdin" src="https://github.com/user-attachments/assets/a6c3d352-e41b-448d-b6cd-3fbca3109f07" />
[Crowdin](https://crowdin.com/) for providing access to their localization platform.
<img width="48" alt="Algolia-mark-square-white" src="https://github.com/user-attachments/assets/6ccad04a-9589-4965-b6a1-d5cb1f4f9e94" />
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.

View File

@ -6,6 +6,7 @@
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Docmost</title>
<!--meta-tags-->
</head>
<body>
<div id="root"></div>

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.8.3",
"version": "0.21.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -15,40 +15,46 @@
"@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "^0.17.6",
"@mantine/core": "^7.14.2",
"@mantine/form": "^7.14.2",
"@mantine/hooks": "^7.14.2",
"@mantine/modals": "^7.14.2",
"@mantine/notifications": "^7.14.2",
"@mantine/spotlight": "^7.14.2",
"@tabler/icons-react": "^3.22.0",
"@tanstack/react-query": "^5.61.4",
"axios": "^1.7.9",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^7.17.0",
"@mantine/form": "^7.17.0",
"@mantine/hooks": "^7.17.0",
"@mantine/modals": "^7.17.0",
"@mantine/notifications": "^7.17.0",
"@mantine/spotlight": "^7.17.0",
"@tabler/icons-react": "^3.34.0",
"@tanstack/react-query": "^5.80.6",
"@tiptap/extension-character-count": "^2.10.3",
"alfaaz": "^1.1.0",
"axios": "^1.9.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"highlightjs-sap-abap": "^0.3.0",
"i18next": "^23.14.0",
"i18next-http-backend": "^2.6.1",
"jotai": "^2.10.3",
"jotai": "^2.12.5",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"katex": "0.16.21",
"lowlight": "^3.2.0",
"mermaid": "^11.4.1",
"jwt-decode": "^4.0.0",
"katex": "0.16.22",
"lowlight": "^3.3.0",
"mermaid": "^11.6.0",
"mitt": "^3.0.1",
"react": "^18.3.1",
"react-arborist": "3.4.0",
"react-clear-modal": "^2.0.11",
"react-clear-modal": "^2.0.15",
"react-dom": "^18.3.1",
"react-drawio": "^1.0.1",
"react-error-boundary": "^4.1.2",
"react-helmet-async": "^2.0.5",
"react-i18next": "^15.0.1",
"react-router-dom": "^7.0.1",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.16",
"zod": "^3.23.8"
"tiptap-extension-global-drag-handle": "^0.1.18",
"zod": "^3.25.56"
},
"devDependencies": {
"@eslint/js": "^9.16.0",
@ -59,7 +65,7 @@
"@types/node": "22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.15.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
@ -72,6 +78,6 @@
"prettier": "^3.4.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0",
"vite": "^6.1.0"
"vite": "^6.3.5"
}
}

View File

@ -148,6 +148,7 @@
"Select role to assign to all invited members": "Rolle für alle eingeladenen Mitglieder auswählen",
"Select theme": "Design auswählen",
"Send invitation": "Einladung senden",
"Invitation sent": "Einladung gesendet",
"Settings": "Einstellungen",
"Setup workspace": "Arbeitsbereich einrichten",
"Sign In": "Anmelden",
@ -244,6 +245,7 @@
"Align left": "Links ausrichten",
"Align right": "Rechts ausrichten",
"Align center": "Zentrieren",
"Justify": "Blocksatz",
"Merge cells": "Zellen zusammenführen",
"Split cell": "Zelle teilen",
"Delete column": "Spalte löschen",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
"Names do not match": "Namen stimmen nicht überein",
"Today, {{time}}": "Heute, {{time}}",
"Yesterday, {{time}}": "Gestern, {{time}}"
"Yesterday, {{time}}": "Gestern, {{time}}",
"Space created successfully": "Der Bereich wurde erfolgreich erstellt",
"Space updated successfully": "Der Bereich wurde erfolgreich aktualisiert",
"Space deleted successfully": "Der Bereich wurde erfolgreich gelöscht",
"Members added successfully": "Mitglieder erfolgreich hinzugefügt",
"Member removed successfully": "Mitglied erfolgreich entfernt",
"Member role updated successfully": "Mitgliederrolle erfolgreich aktualisiert",
"Created by: <b>{{creatorName}}</b>": "Erstellt von: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Erstellt am: {{time}}",
"Edited by {{name}} {{time}}": "Bearbeitet von {{name}} {{time}}",
"Word count: {{wordCount}}": "Wortanzahl: {{wordCount}}",
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
"New update": "Neues Update",
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
"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.",
"Move": "Verschieben",
"Move page": "Seite verschieben",
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
"Table of contents": "Inhaltsverzeichnis",
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen.",
"Share": "Teilen",
"Public sharing": "Öffentliches Teilen",
"Shared by": "Geteilt von",
"Shared at": "Geteilt am",
"Inherits public sharing from": "Erbt das öffentliche Teilen von",
"Share to web": "Im Web teilen",
"Shared to web": "Im Web geteilt",
"Anyone with the link can view this page": "Jeder mit dem Link kann diese Seite ansehen",
"Make this page publicly accessible": "Diese Seite öffentlich zugänglich machen",
"Include sub-pages": "Unterseiten einbeziehen",
"Make sub-pages public too": "Unterseiten auch öffentlich machen",
"Allow search engines to index page": "Suchmaschinen erlauben, die Seite zu indexieren",
"Open page": "Seite öffnen",
"Page": "Seite",
"Delete public share link": "Öffentlichen Freigabelink löschen",
"Delete share": "Freigabe löschen",
"Are you sure you want to delete this shared link?": "Möchten Sie diesen Freigabelink wirklich löschen?",
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
"Share not found": "Freigabe nicht gefunden",
"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"
}

View File

@ -148,6 +148,7 @@
"Select role to assign to all invited members": "Select role to assign to all invited members",
"Select theme": "Select theme",
"Send invitation": "Send invitation",
"Invitation sent": "Invitation sent",
"Settings": "Settings",
"Setup workspace": "Setup workspace",
"Sign In": "Sign In",
@ -339,5 +340,54 @@
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
"Names do not match": "Names do not match",
"Today, {{time}}": "Today, {{time}}",
"Yesterday, {{time}}": "Yesterday, {{time}}"
"Yesterday, {{time}}": "Yesterday, {{time}}",
"Space created successfully": "Space created successfully",
"Space updated successfully": "Space updated successfully",
"Space deleted successfully": "Space deleted successfully",
"Members added successfully": "Members added successfully",
"Member removed successfully": "Member removed successfully",
"Member role updated successfully": "Member role updated successfully",
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Created at: {{time}}",
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
"New update": "New update",
"{{latestVersion}} is available": "{{latestVersion}} is available",
"Default page edit mode": "Default page edit mode",
"Choose your preferred page edit mode. Avoid accidental edits.": "Choose your preferred page edit mode. Avoid accidental edits.",
"Reading": "Reading"
"Delete member": "Delete member",
"Member deleted successfully": "Member deleted successfully",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible.",
"Move": "Move",
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",
"Public sharing": "Public sharing",
"Shared by": "Shared by",
"Shared at": "Shared at",
"Inherits public sharing from": "Inherits public sharing from",
"Share to web": "Share to web",
"Shared to web": "Shared to web",
"Anyone with the link can view this page": "Anyone with the link can view this page",
"Make this page publicly accessible": "Make this page publicly accessible",
"Include sub-pages": "Include sub-pages",
"Make sub-pages public too": "Make sub-pages public too",
"Allow search engines to index page": "Allow search engines to index page",
"Open page": "Open page",
"Page": "Page",
"Delete public share link": "Delete public share link",
"Delete share": "Delete share",
"Are you sure you want to delete this shared link?": "Are you sure you want to delete this shared link?",
"Publicly shared pages from spaces you are a member of will appear here": "Publicly shared pages from spaces you are a member of will appear here",
"Share deleted successfully": "Share deleted successfully",
"Share not found": "Share not found",
"Failed to share page": "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"
}

View File

@ -94,7 +94,7 @@
"Invited members will be granted access to spaces the groups can access": "Los miembros invitados recibirán acceso a los espacios a los que los grupos pueden acceder",
"Join the workspace": "Unirse al espacio de trabajo",
"Language": "Idioma",
"Light": "Ligero",
"Light": "Claro",
"Link copied": "Enlace copiado",
"Login": "Iniciar sesión",
"Logout": "Cerrar sesión",
@ -148,6 +148,7 @@
"Select role to assign to all invited members": "Seleccionar rol para asignar a todos los miembros invitados",
"Select theme": "Seleccionar tema",
"Send invitation": "Enviar invitación",
"Invitation sent": "Invitación enviada",
"Settings": "Ajustes",
"Setup workspace": "Configurar espacio de trabajo",
"Sign In": "Iniciar sesión",
@ -244,6 +245,7 @@
"Align left": "Alinear a la izquierda",
"Align right": "Alinear a la derecha",
"Align center": "Alinear al centro",
"Justify": "Justificar",
"Merge cells": "Combinar celdas",
"Split cell": "Dividir celda",
"Delete column": "Eliminar columna",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
"Names do not match": "Los nombres no coinciden",
"Today, {{time}}": "Hoy, {{time}}",
"Yesterday, {{time}}": "Ayer, {{time}}"
"Yesterday, {{time}}": "Ayer, {{time}}",
"Space created successfully": "Espacio creado con éxito",
"Space updated successfully": "Espacio actualizado con éxito",
"Space deleted successfully": "Espacio eliminado con éxito",
"Members added successfully": "Miembros añadidos con éxito",
"Member removed successfully": "Miembro eliminado con éxito",
"Member role updated successfully": "Rol de miembro actualizado con éxito",
"Created by: <b>{{creatorName}}</b>": "Creado por: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Creado a: {{time}}",
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
"Word count: {{wordCount}}": "Conteo de palabras: {{wordCount}}",
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
"New update": "Nueva actualización",
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
"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.",
"Move": "Mover",
"Move page": "Mover página",
"Move page to a different space.": "Mover página a un espacio diferente.",
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
"Table of contents": "Índice de contenidos",
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos.",
"Share": "Compartir",
"Public sharing": "Compartición pública",
"Shared by": "Compartido por",
"Shared at": "Compartido en",
"Inherits public sharing from": "Hereda la compartición pública de",
"Share to web": "Compartir en la web",
"Shared to web": "Compartido en la web",
"Anyone with the link can view this page": "Cualquiera con el enlace puede ver esta página",
"Make this page publicly accessible": "Hacer esta página accesible públicamente",
"Include sub-pages": "Incluir subpáginas",
"Make sub-pages public too": "Hacer públicas también las subpáginas",
"Allow search engines to index page": "Permitir a los motores de búsqueda indexar la página",
"Open page": "Abrir página",
"Page": "Página",
"Delete public share link": "Eliminar enlace de compartición pública",
"Delete share": "Eliminar compartición",
"Are you sure you want to delete this shared link?": "¿Está seguro de que desea eliminar este enlace compartido?",
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
"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"
}

View File

@ -21,7 +21,7 @@
"Can view": "Peut voir",
"Can view pages in space but not edit.": "Peut voir les pages dans l'espace mais ne peut pas les modifier.",
"Cancel": "Annuler",
"Change email": "Changer l'email",
"Change email": "Changer le courriel",
"Change password": "Changer le mot de passe",
"Change photo": "Changer la photo",
"Choose a role": "Choisir un rôle",
@ -148,6 +148,7 @@
"Select role to assign to all invited members": "Sélectionner le rôle à attribuer à tous les membres invités",
"Select theme": "Sélectionner le thème",
"Send invitation": "Envoyer l'invitation",
"Invitation sent": "Invitation envoyée",
"Settings": "Paramètres",
"Setup workspace": "Configurer l'espace de travail",
"Sign In": "Se connecter",
@ -244,6 +245,7 @@
"Align left": "Aligner à gauche",
"Align right": "Aligner à droite",
"Align center": "Aligner au centre",
"Justify": "Justifier",
"Merge cells": "Fusionner les cellules",
"Split cell": "Diviser la cellule",
"Delete column": "Supprimer la colonne",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
"Names do not match": "Les noms ne correspondent pas",
"Today, {{time}}": "Aujourd'hui, {{time}}",
"Yesterday, {{time}}": "Hier, {{time}}"
"Yesterday, {{time}}": "Hier, {{time}}",
"Space created successfully": "Espace créé avec succès",
"Space updated successfully": "Espace mis à jour avec succès",
"Space deleted successfully": "Espace supprimé avec succès",
"Members added successfully": "Membres ajoutés avec succès",
"Member removed successfully": "Membre supprimé avec succès",
"Member role updated successfully": "Rôle du membre mis à jour avec succès",
"Created by: <b>{{creatorName}}</b>": "Créé par : <b>{{creatorName}}</b>",
"Created at: {{time}}": "Créé à : {{time}}",
"Edited by {{name}} {{time}}": "Modifié par {{name}} {{time}}",
"Word count: {{wordCount}}": "Nombre de mots : {{wordCount}}",
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
"New update": "Nouvelle mise à jour",
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
"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.",
"Move": "Déplacer",
"Move page": "Déplacer la page",
"Move page to a different space.": "Déplacer la page vers un autre espace.",
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
"Table of contents": "",
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
"Share": "Partager",
"Public sharing": "Partage public",
"Shared by": "Partagé par",
"Shared at": "Partagé à",
"Inherits public sharing from": "Hérite du partage public de",
"Share to web": "Partager sur le web",
"Shared to web": "Partagé sur le web",
"Anyone with the link can view this page": "Toute personne avec le lien peut voir cette page",
"Make this page publicly accessible": "Rendre cette page accessible au public",
"Include sub-pages": "Inclure les sous-pages",
"Make sub-pages public too": "Rendre également les sous-pages publiques",
"Allow search engines to index page": "Autoriser les moteurs de recherche à indexer la page",
"Open page": "Ouvrir la page",
"Page": "Page",
"Delete public share link": "Supprimer le lien de partage public",
"Delete share": "Supprimer le partage",
"Are you sure you want to delete this shared link?": "Êtes-vous sûr de vouloir supprimer ce lien partagé ?",
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
"Share deleted successfully": "Partage supprimé avec succès",
"Share not found": "Partage non trouvé",
"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"
}

View File

@ -12,21 +12,21 @@
"Are you sure you want to delete this page?": "Sei sicuro di voler eliminare questa pagina?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Sei sicuro di voler rimuovere questo utente dal gruppo? L'utente perderà l'accesso alle risorse accessibili da questo gruppo.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Sei sicuro di voler rimuovere questo utente dallo spazio? L'utente perderà tutti gli accessi a questo spazio.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sei sicuro di voler ripristinare questa versione? Qualsiasi modifica non salvata come versione andrà persa.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sei sicuro di voler ripristinare questa versione? Qualsiasi modifica non versionata verrà persa.",
"Can become members of groups and spaces in workspace": "Può diventare membro di gruppi e spazi nell'area di lavoro",
"Can create and edit pages in space.": "Può creare e modificare le pagine nello spazio.",
"Can edit": "Può modificare",
"Can manage workspace": "Può gestire lo spazio di lavoro",
"Can manage workspace": "Può gestire l'area di lavoro",
"Can manage workspace but cannot delete it": "Può gestire lo spazio di lavoro ma non può eliminarlo",
"Can view": "Può visualizzare",
"Can view pages in space but not edit.": "Può visualizzare le pagine nello spazio ma non modificarle.",
"Can view pages in space but not edit.": "Può visualizzare le pagine nello spazio ma non può modificarle.",
"Cancel": "Annulla",
"Change email": "Cambia email",
"Change password": "Cambia password",
"Change photo": "Cambia foto",
"Choose a role": "Scegli un ruolo",
"Choose your preferred color scheme.": "Scegli il tuo schema di colori preferito.",
"Choose your preferred interface language.": "Scegli la tua lingua preferita per l'interfaccia.",
"Choose your preferred color scheme.": "Scegli il tema che preferisci.",
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
"Confirm": "Conferma",
"Copy link": "Copia link",
@ -34,7 +34,7 @@
"Create group": "Crea gruppo",
"Create page": "Crea pagina",
"Create space": "Crea spazio",
"Create workspace": "Crea spazio di lavoro",
"Create workspace": "Crea area di lavoro",
"Current password": "Password attuale",
"Dark": "Scuro",
"Date": "Data",
@ -43,21 +43,21 @@
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.",
"Description": "Descrizione",
"Details": "Dettagli",
"e.g ACME": "ad es. ACME",
"e.g ACME": "es. ACME",
"e.g ACME Inc": "es. ACME Inc",
"e.g Developers": "es. Sviluppatori",
"e.g Group for developers": "es. Gruppo per gli sviluppatori",
"e.g product": "ad esempio prodotto",
"e.g product": "es. prodotto",
"e.g Product Team": "es. Team di Prodotto",
"e.g Sales": "ad es. Vendite",
"e.g Space for product team": "ad es. Spazio per il team di prodotto",
"e.g Space for sales team to collaborate": "ad es. Spazio per il team di vendita per collaborare",
"e.g Sales": "es. Vendite",
"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",
"Edit group": "Modifica gruppo",
"Email": "Email",
"Enter a strong password": "Inserisci una password sicura",
"Enter valid email addresses separated by comma or space max_50": "Inserisci indirizzi email validi separati da virgola o spazio [max: 50]",
"enter valid emails addresses": "inserisci indirizzi email validi",
"Enter valid email addresses separated by comma or space max_50": "Inserisci degli indirizzi email validi separati da virgola o spazio [max: 50]",
"enter valid emails addresses": "inserisci degli indirizzi email validi",
"Enter your current password": "Inserisci la tua password attuale",
"enter your full name": "inserisci il tuo nome completo",
"Enter your new password": "Inserisci la tua nuova password",
@ -66,33 +66,33 @@
"Error fetching page data.": "Si è verificato un errore durante il recupero dei dati della pagina.",
"Error loading page history.": "Si è verificato un errore durante il caricamento della cronologia della pagina.",
"Export": "Esporta",
"Failed to create page": "Impossibile creare pagina",
"Failed to create page": "Impossibile creare la pagina",
"Failed to delete page": "Impossibile eliminare la pagina",
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
"Failed to import pages": "Impossibile importare le pagine",
"Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
"Failed to update data": "Impossibile aggiornare i dati",
"Full access": "Accesso completo",
"Full page width": "Larghezza pagina intera",
"Full page width": "Pagina a larghezza intera",
"Full width": "Larghezza intera",
"General": "Generale",
"Group": "Gruppo",
"Group description": "Descrizione del gruppo",
"Group name": "Nome del gruppo",
"Groups": "Gruppi",
"Has full access to space settings and pages.": "Ha pieno accesso alle impostazioni e alle pagine dello spazio.",
"Has full access to space settings and pages.": "Ha pieno accesso alle impostazioni dello spazio e alle sue pagine.",
"Home": "Casa",
"Import pages": "Importa pagine",
"Import pages & space settings": "Importa pagine e impostazioni dello spazio",
"Importing pages": "Importazione pagine",
"invalid invitation link": "link di invito non valido",
"Invitation signup": "Iscrizione invito",
"Invite by email": "Invita via email",
"Invite by email": "Invita tramite email",
"Invite members": "Invita membri",
"Invite new members": "Invita nuovi membri",
"Invited members who are yet to accept their invitation will appear here.": "I membri invitati che non hanno ancora accettato il loro invito appariranno qui.",
"Invited members will be granted access to spaces the groups can access": "I membri invitati avranno accesso agli spazi a cui i gruppi possono accedere",
"Join the workspace": "Unisciti allo spazio di lavoro",
"Join the workspace": "Unisciti all'area di lavoro",
"Language": "Lingua",
"Light": "Chiaro",
"Link copied": "Link copiato",
@ -105,15 +105,15 @@
"members": "membri",
"Members": "Membri",
"My preferences": "Le mie preferenze",
"My Profile": "Il mio profilo",
"My Profile": "Il Mio Profilo",
"My profile": "Il mio profilo",
"Name": "Nome",
"New email": "Nuova email",
"New page": "Nuova pagina",
"New password": "Nuova password",
"No group found": "Nessun gruppo trovato",
"No page history saved yet.": "Nessuna cronologia della pagina salvata.",
"No pages yet": "Nessuna pagina ancora",
"No page history saved yet.": "La pagina non ha una cronologia per ora.",
"No pages yet": "Nessuna pagina per ora",
"No results found...": "Nessun risultato trovato...",
"No user found": "Nessun utente trovato",
"Overview": "Panoramica",
@ -139,49 +139,50 @@
"Role": "Ruolo",
"Save": "Salva",
"Search": "Cerca",
"Search for groups": "Cerca gruppi",
"Search for groups": "Cerca un gruppo",
"Search for users": "Cerca un utente",
"Search for users and groups": "Cerca utenti e gruppi",
"Search for users and groups": "Cerca un utente o un gruppo",
"Search...": "Cerca...",
"Select language": "Seleziona lingua",
"Select role": "Seleziona ruolo",
"Select language": "Seleziona una lingua",
"Select role": "Seleziona un ruolo",
"Select role to assign to all invited members": "Seleziona il ruolo da assegnare a tutti i membri invitati",
"Select theme": "Seleziona tema",
"Select theme": "Seleziona un tema",
"Send invitation": "Invia invito",
"Invitation sent": "Invito inviato",
"Settings": "Impostazioni",
"Setup workspace": "Imposta spazio di lavoro",
"Setup workspace": "Configura l'area di lavoro",
"Sign In": "Accedi",
"Sign Up": "Registrati",
"Slug": "Identificatore",
"Slug": "Slug",
"Space": "Spazio",
"Space description": "Descrizione dello spazio",
"Space menu": "Menu spazio",
"Space name": "Nome dello spazio",
"Space settings": "Impostazioni dello spazio",
"Space slug": "Lumaca spaziale",
"Space slug": "Slug dello spazio",
"Spaces": "Spazi",
"Spaces you belong to": "Spazi a cui appartieni",
"No space found": "Nessuno spazio trovato",
"Search for spaces": "Cerca spazi",
"Search for spaces": "Cerca uno spazio",
"Start typing to search...": "Inizia a digitare per cercare...",
"Status": "Stato",
"Successfully imported": "Importazione riuscita",
"Successfully imported": "Importato con successo",
"Successfully restored": "Ripristinato con successo",
"System settings": "Impostazioni di sistema",
"Theme": "Tema",
"To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.",
"Toggle full page width": "Attiva/disattiva larghezza pagina intera",
"Toggle full page width": "Attiva/disattiva pagina a larghezza intera",
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
"untitled": "senza titolo",
"Untitled": "Senza titolo",
"Updated successfully": "Aggiornato con successo",
"User": "Utente",
"Workspace": "Spazio di lavoro",
"Workspace Name": "Nome dello spazio di lavoro",
"Workspace settings": "Impostazioni dello spazio di lavoro",
"You can change your password here.": "Puoi cambiare la tua password qui.",
"Workspace": "Area di lavoro",
"Workspace Name": "Nome dell'area di lavoro",
"Workspace settings": "Impostazioni dell'area di lavoro",
"You can change your password here.": "Qui puoi cambiare la tua password.",
"Your Email": "La tua email",
"Your import is complete.": "Il tuo importazione è completata.",
"Your import is complete.": "La tua importazione è completata.",
"Your name": "Il tuo nome",
"Your Name": "Il Tuo Nome",
"Your password": "La tua password",
@ -190,18 +191,18 @@
"Comments": "Commenti",
"404 page not found": "404 pagina non trovata",
"Sorry, we can't find the page you are looking for.": "Siamo spiacenti, non riusciamo a trovare la pagina che stai cercando.",
"Take me back to homepage": "Portami alla homepage",
"Forgot password": "Hai dimenticato la password",
"Take me back to homepage": "Torna all'homepage",
"Forgot password": "Password dimenticata",
"Forgot your password?": "Hai dimenticato la password?",
"A password reset link has been sent to your email. Please check your inbox.": "Un link per il reset della password è stato inviato al tuo indirizzo email. Per favore, controlla la tua casella di posta.",
"Send reset link": "Invia link di ripristino",
"Send reset link": "Invia link per il ripristino della password",
"Password reset": "Reimposta password",
"Your new password": "La tua nuova password",
"Set password": "Imposta password",
"Write a comment": "Scrivi un commento",
"Reply...": "Rispondi...",
"Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.",
"No comments yet.": "Nessun commento ancora.",
"No comments yet.": "Nessun commento per ora.",
"Edit comment": "Modifica commento",
"Delete comment": "Elimina commento",
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
@ -216,19 +217,19 @@
"Revoke invitation": "Revoca invito",
"Revoke": "Revoca",
"Don't": "Non",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Sei sicuro di voler revocare questo invito? L'utente non potrà unirsi allo spazio di lavoro.",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Sei sicuro di voler revocare questo invito? L'utente non potrà unirsi all'area di lavoro.",
"Resend invitation": "Rispedisci invito",
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questo workspace.",
"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",
"Copied": "Copiato",
"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 in questo spazio.",
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati di questo spazio.",
"Delete space": "Elimina spazio",
"Are you sure you want to delete this space?": "Sei sicuro di voler eliminare questo spazio?",
"Delete this space with all its pages and data.": "Elimina questo spazio con tutte le sue pagine e i suoi dati.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Tutte le pagine, i commenti, gli allegati e i permessi in questo spazio verranno eliminati in modo irreversibile.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Tutte le pagine, i commenti, gli allegati e i permessi di questo spazio verranno eliminati irreversibilmente.",
"Confirm space name": "Conferma nome spazio",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Digita il nome dello spazio <b>{{spaceName}}</b> per confermare la tua azione.",
"Format": "Formato",
@ -240,10 +241,11 @@
"Export page": "Esporta pagina",
"Export space": "Esporta spazio",
"Export {{type}}": "Esporta {{type}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite di allegati di {{limit}}",
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
"Align left": "Allinea a sinistra",
"Align right": "Allinea a destra",
"Align center": "Allinea al centro",
"Justify": "Giustifica",
"Merge cells": "Unisci celle",
"Split cell": "Dividi cella",
"Delete column": "Elimina colonna",
@ -259,10 +261,10 @@
"Danger": "Pericolo",
"Mermaid diagram error:": "Errore nel diagramma di Mermaid:",
"Invalid Mermaid diagram": "Diagramma di Mermaid non valido",
"Double-click to edit Draw.io diagram": "Doppio clic per modificare il diagramma Draw.io",
"Double-click to edit Draw.io diagram": "Fai doppio clic per modificare il diagramma di Draw.io",
"Exit": "Esci",
"Save & Exit": "Salva ed esci",
"Double-click to edit Excalidraw diagram": "Doppio clic per modificare il diagramma Excalidraw",
"Double-click to edit Excalidraw diagram": "Fai doppio clic per modificare il diagramma di Excalidraw",
"Paste link": "Incolla link",
"Edit link": "Modifica link",
"Remove link": "Rimuovi link",
@ -298,7 +300,7 @@
"To-do List": "Lista delle cose da fare",
"Bullet List": "Elenco Puntato",
"Numbered List": "Elenco Numerato",
"Blockquote": "Blocco di citazione",
"Blockquote": "Citazione",
"Just start typing with plain text.": "Inizia a digitare con testo semplice.",
"Track tasks with a to-do list.": "Tieni traccia delle attività con una lista di cose da fare.",
"Big section heading.": "Intestazione di una grande sezione.",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
"Names do not match": "I nomi non corrispondono",
"Today, {{time}}": "Oggi, {{time}}",
"Yesterday, {{time}}": "Ieri, {{time}}"
"Yesterday, {{time}}": "Ieri, {{time}}",
"Space created successfully": "Spazio creato con successo",
"Space updated successfully": "Spazio aggiornato con successo",
"Space deleted successfully": "Spazio eliminato con successo",
"Members added successfully": "Membri aggiunti con successo",
"Member removed successfully": "Membro rimosso con successo",
"Member role updated successfully": "Ruolo del membro aggiornato con successo",
"Created by: <b>{{creatorName}}</b>": "Creato da: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Creato il: {{time}}",
"Edited by {{name}} {{time}}": "Modificato da {{name}} il {{time}}",
"Word count: {{wordCount}}": "Conteggio parole: {{wordCount}}",
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
"New update": "Nuovo aggiornamento",
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
"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.",
"Move": "Sposta",
"Move page": "Sposta pagina",
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
"Table of contents": "Indice dei contenuti",
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario.",
"Share": "Condividi",
"Public sharing": "Condivisione pubblica",
"Shared by": "Condiviso da",
"Shared at": "Condiviso il",
"Inherits public sharing from": "Eredita la condivisione pubblica da",
"Share to web": "Condividi su web",
"Shared to web": "Condiviso su web",
"Anyone with the link can view this page": "Chiunque abbia il link può visualizzare questa pagina",
"Make this page publicly accessible": "Rendi questa pagina accessibile pubblicamente",
"Include sub-pages": "Includi sotto-pagine",
"Make sub-pages public too": "Rendi pubbliche anche le sotto-pagine",
"Allow search engines to index page": "Permetti ai motori di ricerca di indicizzare la pagina",
"Open page": "Apri pagina",
"Page": "Pagina",
"Delete public share link": "Elimina il link di condivisione pubblica",
"Delete share": "Elimina condivisione",
"Are you sure you want to delete this shared link?": "Sei sicuro di voler eliminare questo link condiviso?",
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
"Share deleted successfully": "Condivisione eliminata con successo",
"Share not found": "Condivisione non trovata",
"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"
}

View File

@ -148,6 +148,7 @@
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
"Select theme": "テーマを選択",
"Send invitation": "招待を送る",
"Invitation sent": "招待が送信されました",
"Settings": "設定",
"Setup workspace": "ワークスペースを設定する",
"Sign In": "サインイン",
@ -244,6 +245,7 @@
"Align left": "左揃え",
"Align right": "右揃え",
"Align center": "中央揃え",
"Justify": "両端揃え",
"Merge cells": "セルを結合",
"Split cell": "セルを分割",
"Delete column": "列を削除",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
"Names do not match": "名前が一致しません",
"Today, {{time}}": "今日、{{time}}",
"Yesterday, {{time}}": "昨日、{{time}}"
"Yesterday, {{time}}": "昨日、{{time}}",
"Space created successfully": "スペースを作成しました",
"Space updated successfully": "スペースを更新しました",
"Space deleted successfully": "スペースが削除されました",
"Members added successfully": "メンバーを追加しました",
"Member removed successfully": "メンバーが削除されました",
"Member role updated successfully": "メンバーのロールを更新しました",
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
"Created at: {{time}}": "が作成しました:{{time}}",
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
"New update": "新規更新",
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
"Delete member": "メンバーを削除する",
"Member deleted successfully": "メンバーが削除されました",
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
"Move": "移動",
"Move page": "ページを移動",
"Move page to a different space.": "ページを別のスペースに移動します。",
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
"Table of contents": "目次",
"Add headings (H1, H2, H3) to generate a table of contents.": "見出しH1、H2、H3を追加して目次を生成します。",
"Share": "共有",
"Public sharing": "公開共有",
"Shared by": "共有者",
"Shared at": "共有日時",
"Inherits public sharing from": "から公開共有を継承する",
"Share to web": "ウェブで共有",
"Shared to web": "ウェブに共有済み",
"Anyone with the link can view this page": "リンクを持っている人はこのページを閲覧できます",
"Make this page publicly accessible": "このページを公開します",
"Include sub-pages": "サブページを含む",
"Make sub-pages public too": "サブページも公開する",
"Allow search engines to index page": "検索エンジンにページのインデックス作成を許可する",
"Open page": "ページを開く",
"Page": "ページ",
"Delete public share link": "公開リンクを削除",
"Delete share": "共有を削除",
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
"Share deleted successfully": "共有が正常に削除されました",
"Share not found": "共有が見つかりません",
"Failed to share page": "ページの共有に失敗しました",
"Copy page": "ページをコピー",
"Copy page to a different space.": "ページを別のスペースにコピーします。",
"Page copied successfully": "ページのコピーに成功しました"
}

View File

@ -58,7 +58,7 @@
"Enter a strong password": "강력한 비밀번호를 입력하세요",
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
"Enter your current password": "현재 비밀번호를 입력하세요",
"Enter your current password": "기존 비밀번호를 입력하세요",
"enter your full name": "전체 이름을 입력하세요",
"Enter your new password": "새 비밀번호를 입력하세요",
"Enter your new preferred email": "새로운 이메일을 입력하세요",
@ -84,7 +84,7 @@
"Home": "홈",
"Import pages": "페이지 가져오기",
"Import pages & space settings": "페이지 및 Space 설정 가져오기",
"Importing pages": "페이지 가져오 중",
"Importing pages": "페이지 가져오 중",
"invalid invitation link": "유효하지 않은 초대 링크",
"Invitation signup": "초대 가입",
"Invite by email": "이메일로 초대",
@ -148,6 +148,7 @@
"Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택",
"Select theme": "배경 선택",
"Send invitation": "초대 보내기",
"Invitation sent": "초대 발송 완료",
"Settings": "설정",
"Setup workspace": "Workspace 설정",
"Sign In": "로그인",
@ -169,7 +170,7 @@
"Successfully restored": "복원 완료",
"System settings": "시스템 설정",
"Theme": "배경",
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 현재 비밀번호와 새 이메일을 입력해야 합니다.",
"To change your email, you have to enter your password and new email.": "이메일을 변경하려면 기존 비밀번호와 새 이메일을 입력해야 합니다.",
"Toggle full page width": "전체 페이지 너비 전환",
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
"untitled": "제목 없음",
@ -218,7 +219,7 @@
"Don't": "하지 않음",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
"Resend invitation": "초대 재전송",
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사이 Workspace에 참여할 수 있습니다.",
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
"Invite link": "초대 링크",
"Copy": "복사",
"Copied": "복사됨",
@ -244,6 +245,7 @@
"Align left": "왼쪽 정렬",
"Align right": "오른쪽 정렬",
"Align center": "가운데 정렬",
"Justify": "정렬",
"Merge cells": "셀 병합",
"Split cell": "셀 분할",
"Delete column": "열 삭제",
@ -255,7 +257,7 @@
"Delete table": "테이블 삭제",
"Info": "정보",
"Success": "완료",
"Warning": "경고",
"Warning": "주의",
"Danger": "위험",
"Mermaid diagram error:": "Mermaid diagram 오류:",
"Invalid Mermaid diagram": "잘못된 Mermaid diagram",
@ -264,7 +266,7 @@
"Save & Exit": "저장 후 나가기",
"Double-click to edit Excalidraw diagram": "Excalidraw diagram을 편집하려면 더블 클릭하세요",
"Paste link": "링크 붙여넣기",
"Edit link": "링크 편집",
"Edit link": "링크 수정",
"Remove link": "링크 제거",
"Add link": "링크 추가",
"Please enter a valid url": "유효한 URL을 입력하세요",
@ -296,11 +298,11 @@
"Heading 2": "제목 2",
"Heading 3": "제목 3",
"To-do List": "할 일 목록",
"Bullet List": "글머리 기호 목록",
"Numbered List": "번호 매기기 목록",
"Bullet List": "글머리 ",
"Numbered List": "문단 번호",
"Blockquote": "인용구",
"Just start typing with plain text.": "일반 텍스트로 입력을 시작하세요.",
"Track tasks with a to-do list.": "할 일 목록으로 작업을 추적하세요.",
"Track tasks with a to-do list.": "할 일 목록으로 작업을 정리하세요.",
"Big section heading.": "대제목.",
"Medium section heading.": "중제목.",
"Small section heading.": "소제목.",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
"Names do not match": "이름이 일치하지 않습니다",
"Today, {{time}}": "오늘, {{time}}",
"Yesterday, {{time}}": "어제, {{time}}"
"Yesterday, {{time}}": "어제, {{time}}",
"Space created successfully": "공간 생성 완료",
"Space updated successfully": "공간이 성공적으로 업데이트되었습니다",
"Space deleted successfully": "스페이스 삭제 완료",
"Members added successfully": "회원 추가 완료",
"Member removed successfully": "멤버가 성공적으로 제거되었습니다",
"Member role updated successfully": "회원 역할이 성공적으로 업데이트되었습니다",
"Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>",
"Created at: {{time}}": "생성 날짜: {{time}}",
"Edited by {{name}} {{time}}": "{{name}}님이 편집함 {{time}}",
"Word count: {{wordCount}}": "단어 수: {{wordCount}}",
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
"New update": "새로운 업데이트",
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
"Delete member": "회원 삭제",
"Member deleted successfully": "멤버가 성공적으로 제거되었습니다",
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
"Move": "이동",
"Move page": "페이지 이동",
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
"Table of contents": "목차",
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요.",
"Share": "공유",
"Public sharing": "공개 공유",
"Shared by": "공유자",
"Shared at": "공유 시간",
"Inherits public sharing from": "로부터 공개 공유를 상속함",
"Share to web": "웹에 공유",
"Shared to web": "웹에 공유됨",
"Anyone with the link can view this page": "링크가 있는 사람은 이 페이지를 볼 수 있습니다",
"Make this page publicly accessible": "이 페이지를 공개적으로 접근 가능하게 만들기",
"Include sub-pages": "하위 페이지 포함",
"Make sub-pages public too": "하위 페이지도 공개로 설정",
"Allow search engines to index page": "검색 엔진이 페이지를 색인할 수 있도록 허용",
"Open page": "페이지 열기",
"Page": "페이지",
"Delete public share link": "공유 링크 삭제",
"Delete share": "공유 삭제",
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
"Share 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"
}

View File

@ -0,0 +1,390 @@
{
"Account": "Account",
"Active": "Actief",
"Add": "Toevoegen",
"Add group members": "Groepsleden toevoegen",
"Add groups": "Groepen Toevoegen",
"Add members": "Leden toevoegen",
"Add to groups": "Toevoegen aan groepen",
"Add space members": "Voeg leden toe ruimte",
"Admin": "Beheerder",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Weet je zeker dat je deze groep wilt verwijderen? Leden verliezen toegang tot documenten waar deze groep toegang toe heeft.",
"Are you sure you want to delete this page?": "Weet u zeker dat u deze pagina wil verwijderen?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Weet je zeker dat je deze groep wilt verwijderen? Leden verliezen toegang tot documenten waar deze groep toegang toe heeft.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Weet u zeker dat u deze gebruiker van de ruimte wilt verwijderen? De gebruiker zal alle toegang tot deze ruimte verliezen.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Weet u zeker dat u deze versie wilt herstellen? Wijzigingen die geen versie hebben zullen verloren gaan.",
"Can become members of groups and spaces in workspace": "Kunnen lid worden van groepen en ruimtes in de werkruimte",
"Can create and edit pages in space.": "Kan pagina's in de ruimte maken en bewerken.",
"Can edit": "Kan bewerken",
"Can manage workspace": "Kan werkruimte beheren",
"Can manage workspace but cannot delete it": "Kan een werkruimte beheren, maar kan deze niet verwijderen",
"Can view": "Kan bekijken",
"Can view pages in space but not edit.": "Kan pagina's in de ruimte bekijken maar niet bewerken.",
"Cancel": "Annuleren",
"Change email": "Wijzig e-mailadres",
"Change password": "Wijzig wachtwoord",
"Change photo": "Wijzig foto",
"Choose a role": "Kies een rol",
"Choose your preferred color scheme.": "Kies uw gewenste kleurenschema.",
"Choose your preferred interface language.": "Kies uw gewenste interfacetaal.",
"Choose your preferred page width.": "Kies uw gewenste paginabreedte.",
"Confirm": "Bevestig",
"Copy link": "Link kopiëren",
"Create": "Aanmaken",
"Create group": "Groep aanmaken",
"Create page": "Pagina aanmaken",
"Create space": "Ruimte aanmaken",
"Create workspace": "Wwerkruimte aanmaken",
"Current password": "Huidig wachtwoord",
"Dark": "Donker",
"Date": "Datum",
"Delete": "Verwijderen",
"Delete group": "Groep verwijderen",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Weet u zeker dat u deze pagina wilt verwijderen? Dit zal de subpagina's en paginageschiedenis verwijderen. Deze actie kan niet ongedaan gemaakt worden.",
"Description": "Beschrijving",
"Details": "Details",
"e.g ACME": "bijv. ACME",
"e.g ACME Inc": "bijv. ACME Inc",
"e.g Developers": "bijv. Ontwikkelaars",
"e.g Group for developers": "bijv. Groep voor ontwikkelaars",
"e.g product": "bijv. product",
"e.g Product Team": "bijv. Product Team",
"e.g Sales": "bijv. Verkopen",
"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",
"Edit group": "Groep bewerken",
"Email": "E-mailadres",
"Enter a strong password": "Voer een sterk wachtwoord in",
"Enter valid email addresses separated by comma or space max_50": "Voer geldige e-mailadressen in, gescheiden door komma of spatie [max: 50]",
"enter valid emails addresses": "voer geldige e-mailadressen in",
"Enter your current password": "Voer uw huidige wachtwoord in",
"enter your full name": "voer uw volledige naam in",
"Enter your new password": "Voer uw nieuwe wachtwoord in",
"Enter your new preferred email": "Voer uw nieuwe e-mailadres in",
"Enter your password": "Voer uw wachtwoord in",
"Error fetching page data.": "Fout bij het ophalen van paginagegevens.",
"Error loading page history.": "Fout bij het laden van de paginageschiedenis.",
"Export": "Exporteer",
"Failed to create page": "Pagina aanmaken mislukt",
"Failed to delete page": "Verwijderen van pagina mislukt",
"Failed to fetch recent pages": "Kan recente pagina's niet ophalen",
"Failed to import pages": "Pagina's importeren mislukt",
"Failed to load page. An error occurred.": "Laden van pagina mislukt. Er is een fout opgetreden.",
"Failed to update data": "Bijwerken van gegevens mislukt",
"Full access": "Volledig toegang",
"Full page width": "Volledige pagina breedte",
"Full width": "Volledige breedte",
"General": "Algemeen",
"Group": "Groep",
"Group description": "Groepsomschrijving",
"Group name": "Groepsnaam",
"Groups": "Groepen",
"Has full access to space settings and pages.": "Heeft volledige toegang tot ruimte instellingen en pagina's.",
"Home": "Startpagina",
"Import pages": "Importeer pagina's",
"Import pages & space settings": "Importeer pagina en ruimte instellingen",
"Importing pages": "Importeer pagina's",
"invalid invitation link": "ongeldige uitnodigingslink",
"Invitation signup": "Uitnodiging aanmelding",
"Invite by email": "Uitnodigen via e-mail",
"Invite members": "Leden uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen",
"Invited members who are yet to accept their invitation will appear here.": "Uigenodigde leden die hun uitnodiging nog moeten accepteren zullen hier worden getoond.",
"Invited members will be granted access to spaces the groups can access": "Uitgenodigde leden wordt toegang gegeven tot ruimtes de groepen toegang toe heeft",
"Join the workspace": "Word lid van de werkruimte",
"Language": "Taal",
"Light": "Licht",
"Link copied": "Link gekopieerd",
"Login": "Inloggen",
"Logout": "Uitloggen",
"Manage Group": "Groep beheren",
"Manage members": "Leden beheren",
"member": "lid",
"Member": "Lid",
"members": "leden",
"Members": "Leden",
"My preferences": "Mijn voorkeuren",
"My Profile": "Mijn profiel",
"My profile": "Mijn profiel",
"Name": "Naam",
"New email": "Nieuw e-mail",
"New page": "Nieuwe pagina",
"New password": "Nieuw wachtwoord",
"No group found": "Geen groep gevonden",
"No page history saved yet.": "Er is nog geen pagina geschiedenis opgeslagen.",
"No pages yet": "Nog geen pagina's",
"No results found...": "Geen resultaten gevonden...",
"No user found": "Geen gebruiker gevonden",
"Overview": "Overzicht",
"Owner": "Eigenaar",
"page": "pagina",
"Page deleted successfully": "Pagina succesvol verwijderd",
"Page history": "Pagina geschiedenis",
"Page import is in progress. Please do not close this tab.": "Importeren van pagina's is bezig. Sluit dit tabblad niet.",
"Pages": "Pagina's",
"pages": "pagina's",
"Password": "Wachtwoord",
"Password changed successfully": "Wachtwoord met succes gewijzigd",
"Pending": "Wachtende",
"Please confirm your action": "Bevestig alstublieft uw actie",
"Preferences": "Voorkeuren",
"Print PDF": "PDF afdrukken",
"Profile": "Profiel",
"Recently updated": "Recent bijgewerkt",
"Remove": "Verwijderen",
"Remove group member": "Lid uit groep verwijderd",
"Remove space member": "Lid uit ruimte verwijderd",
"Restore": "Herstellen",
"Role": "Rol",
"Save": "Opslaan",
"Search": "Zoeken",
"Search for groups": "Zoek naar groepen",
"Search for users": "Zoek naar gebruikers",
"Search for users and groups": "Zoek naar gebruikers en groepen",
"Search...": "Zoeken...",
"Select language": "Selecteer taal",
"Select role": "Selecteer rol",
"Select role to assign to all invited members": "Selecteer rol en wijs toe aan alle uitgenodigde leden",
"Select theme": "Selecteer thema",
"Send invitation": "Uitnodiging versturen",
"Invitation sent": "Uitnodiging verzonden",
"Settings": "Instellingen",
"Setup workspace": "Werkruimte instellen",
"Sign In": "Inloggen",
"Sign Up": "Aanmelden",
"Slug": "Afkorting",
"Space": "Ruimte",
"Space description": "Omschrijving van de ruimte",
"Space menu": "Ruimte menu",
"Space name": "Naam ruimte",
"Space settings": "Ruimte instellingen",
"Space slug": "Ruimte afkorting",
"Spaces": "Ruimtes",
"Spaces you belong to": "Ruimtes waar je bij hoort",
"No space found": "Geen ruimte gevonden",
"Search for spaces": "Zoek naar ruimtes",
"Start typing to search...": "Begin met typen om te zoeken...",
"Status": "Status",
"Successfully imported": "Succesvol geïmporteerd",
"Successfully restored": "Succesvol hersteld",
"System settings": "Systeem instellingen",
"Theme": "Thema",
"To change your email, you have to enter your password and new email.": "Om uw e-mailadres te wijzigen, moet u uw wachtwoord en nieuwe e-mail invullen.",
"Toggle full page width": "Schakel volledige pagina breedte in",
"Unable to import pages. Please try again.": "Pagina's importeren is niet gelukt. Probeer het opnieuw.",
"untitled": "naamloos",
"Untitled": "Naamloos",
"Updated successfully": "Succesvol bijgewerkt",
"User": "Gebruiker",
"Workspace": "Werkruimte",
"Workspace Name": "Naam werkruimte",
"Workspace settings": "Instellingen werkruimte",
"You can change your password here.": "U kunt hier uw wachtwoord wijzigen.",
"Your Email": "Uw e-mailadres",
"Your import is complete.": "Uw import is voltooid.",
"Your name": "Uw naam",
"Your Name": "Uw Naam",
"Your password": "Uw wachtwoord",
"Your password must be a minimum of 8 characters.": "Uw wachtwoord moet minimaal 8 tekens bevatten.",
"Sidebar toggle": "Zijbalk toggelen",
"Comments": "Opmerkingen",
"404 page not found": "404 pagina niet gevonden",
"Sorry, we can't find the page you are looking for.": "Sorry, we kunnen de pagina die u zoekt niet vinden.",
"Take me back to homepage": "Ga terug naar de homepage",
"Forgot password": "Wachtwoord vergeten",
"Forgot your password?": "Wachtwoord vergeten?",
"A password reset link has been sent to your email. Please check your inbox.": "Een link om uw wachtwoord te resetten is verstuurd naar uw e-mail. Controleer uw inbox.",
"Send reset link": "Verstuur een link om uw wachtwoord te herstellen",
"Password reset": "Wachtwoord opnieuw instellen",
"Your new password": "Uw nieuwe wachtwoord",
"Set password": "Voer wachtwoord in",
"Write a comment": "Schrijf een reactie",
"Reply...": "Antwoord...",
"Error loading comments.": "Fout bij het laden van reacties.",
"No comments yet.": "Nog geen reacties.",
"Edit comment": "Bewerk reactie",
"Delete comment": "Verwijder reactie",
"Are you sure you want to delete this comment?": "Weet je zeker dat je deze reactie wilt verwijderen?",
"Comment created successfully": "Reactie succesvol aangemaakt",
"Error creating comment": "Fout bij het aanmaken van reactie",
"Comment updated successfully": "Opmerking succesvol bijgewerkt",
"Failed to update comment": "Bijwerken van reactie mislukt",
"Comment deleted successfully": "Reactie met succes verwijderd",
"Failed to delete comment": "Verwijderen van reactie mislukt",
"Comment resolved successfully": "Reactie succesvol opgelost",
"Failed to resolve comment": "Reactie oplossen mislukt",
"Revoke invitation": "Uitnodiging intrekken",
"Revoke": "Intrekken",
"Don't": "Niet doen",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Weet u zeker dat u deze uitnodiging wilt intrekken? De gebruiker kan niet deelnemen aan de werkruimte.",
"Resend invitation": "Uitnodiging opnieuw verzenden",
"Anyone with this link can join this workspace.": "Iedereen met deze link kan zich aansluiten bij deze werkruimte.",
"Invite link": "Uitnodigingslink",
"Copy": "Kopieer",
"Copied": "Gekopieerd",
"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.",
"Delete space": "Verwijder ruimte",
"Are you sure you want to delete this space?": "Weet u zeker dat u deze ruimte wil verwijderen?",
"Delete this space with all its pages and data.": "Verwijder deze ruimte met alle pagina's en gegevens.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Alle pagina's, opmerkingen, bijlagen en permissies in deze ruimte zullen onherroepelijk worden verwijderd.",
"Confirm space name": "Bevestig naam van ruimte",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Typ de ruimtenaam <b>{{spaceName}}</b> om uw actie te bevestigen.",
"Format": "Formaat",
"Include subpages": "Inclusief onderliggend pagina's",
"Include attachments": "Inclusief bijlages",
"Select export format": "Selecteer export formaat",
"Export failed:": "Exporteren mislukt:",
"export error": "Exporteer fout",
"Export page": "Exporteer pagina",
"Export space": "Exporteer ruimte",
"Export {{type}}": "Exporteer {{type}}",
"File exceeds the {{limit}} attachment limit": "Bestand overschrijdt de bijlagelimiet van {{limit}}",
"Align left": "Links uitlijnen",
"Align right": "Rechts uitlijnen",
"Align center": "Centreren",
"Justify": "Uitvullen",
"Merge cells": "Cellen samenvoegen",
"Split cell": "Cel splitsen",
"Delete column": "Kolom verwijderen",
"Delete row": "Rij verwijderen",
"Add left column": "Linker kolom toevoegen",
"Add right column": "Rechter kolom toevoegen",
"Add row above": "Rij hierboven toevoegen",
"Add row below": "Rij hieronder toevoegen",
"Delete table": "Verwijder tabel",
"Info": "Info",
"Success": "Geslaagd",
"Warning": "Waarschuwing",
"Danger": "Gevaar",
"Mermaid diagram error:": "Mermaid diagram fout:",
"Invalid Mermaid diagram": "Ongeldig Mermaid diagram",
"Double-click to edit Draw.io diagram": "Dubbelklik om Draw.io diagram te bewerken",
"Exit": "Afsluiten",
"Save & Exit": "Opslaan & Afsluiten",
"Double-click to edit Excalidraw diagram": "Dubbelklik om Excalidraw diagram te bewerken",
"Paste link": "Link plakken",
"Edit link": "Link bewerken",
"Remove link": "Link verwijderen",
"Add link": "Link toevoegen",
"Please enter a valid url": "Voer een geldige URL in",
"Empty equation": "Lege vergelijking",
"Invalid equation": "Ongeldige vergelijking",
"Color": "Kleur",
"Text color": "Tekstkleur",
"Default": "Standaard",
"Blue": "Blauw",
"Green": "Groen",
"Purple": "Paars",
"Red": "Rood",
"Yellow": "Geel",
"Orange": "Oranje",
"Pink": "Roze",
"Gray": "Grijs",
"Embed link": "Link insluiten",
"Invalid {{provider}} embed link": "Ongeldige {{provider}} insluitingslink",
"Embed {{provider}}": "Insluiten {{provider}}",
"Enter {{provider}} link to embed": "Voer {{provider}} link in om in te voegen",
"Bold": "Dikgedrukt",
"Italic": "Schuingedrukt",
"Underline": "Onderstrepen",
"Strike": "Doorhalen",
"Code": "Code",
"Comment": "Reactie",
"Text": "Tekst",
"Heading 1": "Kop 1",
"Heading 2": "Kop 2",
"Heading 3": "Kop 3",
"To-do List": "Takenlijst",
"Bullet List": "Opsommingslijst",
"Numbered List": "Genummerde lijst",
"Blockquote": "Blockquote",
"Just start typing with plain text.": "Begin met typen.",
"Track tasks with a to-do list.": "Houd taken bij met een takenlijst.",
"Big section heading.": "Grote sectie kop.",
"Medium section heading.": "Middelgrote sectie kop.",
"Small section heading.": "Kleine sectie kop.",
"Create a simple bullet list.": "Maak een eenvoudige opsommingslijst aan.",
"Create a list with numbering.": "Maak een lijst met nummering.",
"Create block quote.": "Maak een block quote.",
"Insert code snippet.": "Codefragment invoegen.",
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Table": "Tabel",
"Insert a table.": "Voeg een tabel in.",
"Insert collapsible block.": "Inklapbaar blok invoegen.",
"Video": "Video",
"Divider": "Scheidingslijn",
"Quote": "Quote",
"Image": "Afbeelding",
"File attachment": "Bestand bijlage",
"Toggle block": "Schakel blok in/uit",
"Callout": "Opmerking",
"Insert callout notice.": "Invoegen opmerking.",
"Math inline": "Wiskundige inline",
"Insert inline math equation.": "Wiskundige inline vergelijking invoegen.",
"Math block": "Wiskunde blok",
"Insert math equation": "Wiskundige inline vergelijking invoegen",
"Mermaid diagram": "Mermaid diagram",
"Insert mermaid diagram": "Voeg mermaid diagram in",
"Insert and design Drawio diagrams": "Drawio diagrammen invoegen en ontwerpen",
"Insert current date": "Huidige datum invoeren",
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
"Multiple": "Meerdere",
"Heading {{level}}": "Kop {{level}}",
"Toggle title": "Schakel titel in/uit",
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
"Names do not match": "Namen komen niet overeen",
"Today, {{time}}": "Vandaag, {{time}}",
"Yesterday, {{time}}": "Gisteren, {{time}}",
"Space created successfully": "Ruimte succesvol aangemaakt",
"Space updated successfully": "Ruimte succesvol bijgewerkt",
"Space deleted successfully": "Ruimte succesvol verwijderd",
"Members added successfully": "Leden succesvol toegevoegd",
"Member removed successfully": "Lid succesvol verwijderd",
"Member role updated successfully": "Lidrol succesvol bijgewerkt",
"Created by: <b>{{creatorName}}</b>": "Gemaakt door: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Aangemaakt op: {{time}}",
"Edited by {{name}} {{time}}": "Bewerkt door {{name}} {{time}}",
"Word count: {{wordCount}}": "Aantal woorden: {{wordCount}}",
"Character count: {{characterCount}}": "Aantal tekens: {{characterCount}}",
"New update": "Nieuwe update",
"{{latestVersion}} is available": "{{latestVersion}} is beschikbaar",
"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.",
"Move": "Verplaatsen",
"Move page": "Pagina verplaatsen",
"Move page to a different space.": "Verplaats pagina naar een andere ruimte.",
"Real-time editor connection lost. Retrying...": "Realtime editorverbinding verloren. Opnieuw proberen...",
"Table of contents": "Inhoudsopgave",
"Add headings (H1, H2, H3) to generate a table of contents.": "Voeg koppen (H1, H2, H3) toe om een inhoudsopgave te genereren.",
"Share": "Delen",
"Public sharing": "Openbaar delen",
"Shared by": "Gedeeld door",
"Shared at": "Gedeeld op",
"Inherits public sharing from": "Erft openbaar delen van",
"Share to web": "Delen naar web",
"Shared to web": "Gedeeld naar web",
"Anyone with the link can view this page": "Iedereen met de link kan deze pagina bekijken",
"Make this page publicly accessible": "Maak deze pagina openbaar toegankelijk",
"Include sub-pages": "Inclusief subpagina's",
"Make sub-pages public too": "Maak subpagina's ook openbaar",
"Allow search engines to index page": "Sta zoekmachines toe om pagina te indexeren",
"Open page": "Pagina openen",
"Page": "Pagina",
"Delete public share link": "Verwijder openbare deel-link",
"Delete share": "Verwijder deel",
"Are you sure you want to delete this shared link?": "Weet u zeker dat u deze gedeelde link wilt verwijderen?",
"Publicly shared pages from spaces you are a member of will appear here": "Openbaar gedeelde pagina's van ruimtes waarvan u lid bent, verschijnen hier",
"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"
}

View File

@ -148,6 +148,7 @@
"Select role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",
"Select theme": "Selecionar tema",
"Send invitation": "Enviar convite",
"Invitation sent": "Convite enviado",
"Settings": "Configurações",
"Setup workspace": "Configurar workspace",
"Sign In": "Entrar",
@ -244,6 +245,7 @@
"Align left": "Alinhar à esquerda",
"Align right": "Alinhar à direita",
"Align center": "Alinhar ao centro",
"Justify": "Justificar",
"Merge cells": "Mesclar células",
"Split cell": "Dividir célula",
"Delete column": "Excluir coluna",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
"Names do not match": "Os nomes não coincidem",
"Today, {{time}}": "Hoje, {{time}}",
"Yesterday, {{time}}": "Ontem, {{time}}"
"Yesterday, {{time}}": "Ontem, {{time}}",
"Space created successfully": "Espaço criado com sucesso",
"Space updated successfully": "Espaço atualizado com sucesso",
"Space deleted successfully": "Espaço excluído com sucesso",
"Members added successfully": "Membros adicionados com sucesso",
"Member removed successfully": "Membro removido com sucesso",
"Member role updated successfully": "Função do membro atualizada com sucesso",
"Created by: <b>{{creatorName}}</b>": "Criado por: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Criado em: {{time}}",
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
"Word count: {{wordCount}}": "Contagem de palavras: {{wordCount}}",
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
"New update": "Nova atualização",
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
"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.",
"Move": "Mover",
"Move page": "Mover página",
"Move page to a different space.": "Mover página para um espaço diferente.",
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
"Table of contents": "Tabela de conteúdos",
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo.",
"Share": "Compartilhar",
"Public sharing": "Compartilhamento público",
"Shared by": "Compartilhado por",
"Shared at": "Compartilhado em",
"Inherits public sharing from": "Herdado do compartilhamento público de",
"Share to web": "Compartilhar na web",
"Shared to web": "Compartilhado na web",
"Anyone with the link can view this page": "Qualquer um com o link pode ver esta página",
"Make this page publicly accessible": "Tornar esta página publicamente acessível",
"Include sub-pages": "Incluir sub-páginas",
"Make sub-pages public too": "Tornar as sub-páginas públicas também",
"Allow search engines to index page": "Permitir que mecanismos de busca indexem a página",
"Open page": "Abrir página",
"Page": "Página",
"Delete public share link": "Excluir o link público compartilhado",
"Delete share": "Excluir compartilhamento",
"Are you sure you want to delete this shared link?": "Tem certeza de que deseja excluir este link compartilhado?",
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
"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"
}

View File

@ -13,11 +13,11 @@
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочем пространстве",
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
"Can edit": "Может изменять",
"Can manage workspace": "Может управлять рабочим пространством",
"Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
"Can manage workspace": "Может управлять рабочей областью",
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить",
"Can view": "Может просматривать",
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
"Cancel": "Отменить",
@ -34,7 +34,7 @@
"Create group": "Создать группу",
"Create page": "Создать страницу",
"Create space": "Создать пространство",
"Create workspace": "Создать рабочее пространство",
"Create workspace": "Создать рабочую область",
"Current password": "Текущий пароль",
"Dark": "Темная",
"Date": "Дата",
@ -82,7 +82,7 @@
"Groups": "Группы",
"Has full access to space settings and pages.": "Имеет полный доступ к настройкам пространства и страницам.",
"Home": "Главная",
"Import pages": "Импортировать страницы",
"Import pages": "Импорт страниц",
"Import pages & space settings": "Импорт страниц и настройки пространства",
"Importing pages": "Импортирование страниц",
"invalid invitation link": "ссылка на приглашение недействительна",
@ -92,7 +92,7 @@
"Invite new members": "Пригласить новых участников",
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
"Join the workspace": "Присоединиться к рабочему пространству",
"Join the workspace": "Присоединиться к рабочей области",
"Language": "Язык",
"Light": "Светлая",
"Link copied": "Ссылка скопирована",
@ -128,7 +128,7 @@
"Password changed successfully": "Пароль успешно изменён",
"Pending": "В ожидании",
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
"Preferences": "Внешний вид",
"Preferences": "Настройки",
"Print PDF": "Печать PDF",
"Profile": "Профиль",
"Recently updated": "Обновлено недавно",
@ -148,8 +148,9 @@
"Select role to assign to all invited members": "Выберите роль для всех приглашённых участников",
"Select theme": "Выберите тему",
"Send invitation": "Отправить приглашение",
"Invitation sent": "Приглашение отправлено",
"Settings": "Настройки",
"Setup workspace": "Настроить рабочее пространство",
"Setup workspace": "Настроить рабочую область",
"Sign In": "Вход",
"Sign Up": "Регистрация",
"Slug": "Slug",
@ -176,9 +177,9 @@
"Untitled": "Без названия",
"Updated successfully": "Обновлено успешно",
"User": "Пользователь",
"Workspace": "Рабочее пространство",
"Workspace Name": "Имя рабочего пространства",
"Workspace settings": "Настройки рабочего пространства",
"Workspace": "Рабочая область",
"Workspace Name": "Имя рабочей области",
"Workspace settings": "Настройки рабочей области",
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
"Your Email": "Ваш адрес электронной почты",
"Your import is complete.": "Ваш импорт завершен.",
@ -216,9 +217,9 @@
"Revoke invitation": "Отозвать приглашение",
"Revoke": "Отозвать",
"Don't": "Нет",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочему пространству.",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочей области.",
"Resend invitation": "Отправить приглашение повторно",
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
"Invite link": "Ссылка для приглашения",
"Copy": "Копировать",
"Copied": "Скопировано",
@ -244,12 +245,13 @@
"Align left": "По левому краю",
"Align right": "По правому краю",
"Align center": "По центру",
"Justify": "По ширине",
"Merge cells": "Объединить ячейки",
"Split cell": "Разделить ячейку",
"Delete column": "Удалить столбец",
"Delete row": "Удалить строку",
"Add left column": "Добавить левый столбец",
"Add right column": "Добавить правый столбец",
"Add left column": "Добавить столбец слева",
"Add right column": "Добавить столбец справа",
"Add row above": "Добавить строку выше",
"Add row below": "Добавить строку ниже",
"Delete table": "Удалить таблицу",
@ -320,23 +322,69 @@
"Quote": "Цитата",
"Image": "Изображение",
"File attachment": "Прикрепленный файл",
"Toggle block": "Переключить блок",
"Toggle block": "Сворачиваемый блок",
"Callout": "Выноска",
"Insert callout notice.": "Вставить выноску с сообщением.",
"Math inline": "Формула в строке",
"Math inline": "Формула",
"Insert inline math equation.": "Вставить математическое выражение в строку.",
"Math block": "Блок формул",
"Insert math equation": "Вставить математическое выражение",
"Mermaid diagram": "Диаграмма Mermaid",
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
"Insert and design Drawio diagrams": "Вставьте и редактируйте диаграммы Draw.io",
"Insert and design Drawio diagrams": "Вставить и рисовать диаграммы Draw.io",
"Insert current date": "Вставить текущую дату",
"Draw and sketch excalidraw diagrams": "Создайте и рисуйте диаграммы Excalidraw",
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
"Multiple": "Несколько",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для выбора команд",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
"Names do not match": "Названия не совпадают",
"Today, {{time}}": "Сегодня, {{time}}",
"Yesterday, {{time}}": "Вчера, {{time}}"
"Yesterday, {{time}}": "Вчера, {{time}}",
"Space created successfully": "Пространство успешно создано",
"Space updated successfully": "Пространство успешно обновлено",
"Space deleted successfully": "Пространство успешно удалено",
"Members added successfully": "Участники успешно добавлены",
"Member removed successfully": "Участник успешно удален",
"Member role updated successfully": "Роль участника успешно обновлена",
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Дата создания: {{time}}",
"Edited by {{name}} {{time}}": "Изменено {{name}} {{time}}",
"Word count: {{wordCount}}": "Количество слов: {{wordCount}}",
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
"New update": "Новое обновление",
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
"Delete member": "Удалить участника",
"Member deleted successfully": "Участник успешно удален",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
"Move": "Переместить",
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Table of contents": "Содержание",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
"Public sharing": "Общий доступ",
"Shared by": "Поделился",
"Shared at": "Поделился в",
"Inherits public sharing from": "Наследует общий доступ от",
"Share to web": "Поделиться в интернете",
"Shared to web": "Размещено в интернете",
"Anyone with the link can view this page": "Любой, у кого есть ссылка, может просмотреть эту страницу",
"Make this page publicly accessible": "Сделать эту страницу общедоступной",
"Include sub-pages": "Включить подстраницы",
"Make sub-pages public too": "Сделать подстраницы также общедоступными",
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
"Open page": "Открыть страницу",
"Page": "Страница",
"Delete public share link": "Удалить ссылку на общий доступ",
"Delete share": "Удалить общий доступ",
"Are you sure you want to delete this shared link?": "Вы уверены, что хотите удалить эту ссылку общего доступа?",
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
"Share deleted successfully": "Общий доступ успешно удален",
"Share not found": "Общий доступ не найден",
"Failed to share page": "Не удалось поделиться страницей",
"Copy page": "Копировать страницу",
"Copy page to a different space.": "Копировать страницу в другое пространство.",
"Page copied successfully": "Страница успешно скопирована"
}

View File

@ -0,0 +1,390 @@
{
"Account": "Обліковий запис",
"Active": "Активний",
"Add": "Додати",
"Add group members": "Додати учасників групи",
"Add groups": "Додати групи",
"Add members": "Додати учасників",
"Add to groups": "Додати до груп",
"Add space members": "Додати учасників простору",
"Admin": "Адміністратор",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цю групу? Учасники втратять доступ до матеріалів, до яких ця група має доступ.",
"Are you sure you want to delete this page?": "Ви впевнені, що хочете видалити цю сторінку?",
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Ви впевнені, що хочете видалити цього користувача з групи? Користувач втратить доступ до матеріалів, до яких ця група має доступ.",
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Ви впевнені, що хочете видалити цього користувача з простору? Користувач втратить весь доступ до цього простору.",
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Ви впевнені, що хочете відновити цю версію? Усі не збережені зміни будуть втрачені.",
"Can become members of groups and spaces in workspace": "Можуть ставати учасниками груп та просторів у робочій області",
"Can create and edit pages in space.": "Може створювати та редагувати сторінки в просторі.",
"Can edit": "Може редагувати",
"Can manage workspace": "Може керувати робочою областю",
"Can manage workspace but cannot delete it": "Може керувати робочою областю, але не може її видалити",
"Can view": "Може переглядати",
"Can view pages in space but not edit.": "Може переглядати сторінки в просторі, але не може їх редагувати.",
"Cancel": "Скасувати",
"Change email": "Змінити електронну пошту",
"Change password": "Змінити пароль",
"Change photo": "Змінити фото",
"Choose a role": "Оберіть роль",
"Choose your preferred color scheme.": "Оберіть бажану кольорову схему.",
"Choose your preferred interface language.": "Оберіть бажану мову інтерфейсу.",
"Choose your preferred page width.": "Оберіть бажану ширину сторінки.",
"Confirm": "Підтвердити",
"Copy link": "Копіювати посилання",
"Create": "Створити",
"Create group": "Створити групу",
"Create page": "Створити сторінку",
"Create space": "Створити простір",
"Create workspace": "Створити робочу область",
"Current password": "Поточний пароль",
"Dark": "Темна",
"Date": "Дата",
"Delete": "Видалити",
"Delete group": "Видалити групу",
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Ви впевнені, що хочете видалити цю сторінку? Це видалить її дочірні сторінки, а також історію сторінки. Ця дія необоротна.",
"Description": "Опис",
"Details": "Деталі",
"e.g ACME": "наприклад, ACME",
"e.g ACME Inc": "наприклад, ACME Inc",
"e.g Developers": "наприклад, Розробники",
"e.g Group for developers": "наприклад, Група для розробників",
"e.g product": "наприклад, продукт",
"e.g Product Team": "наприклад, Продуктова команда",
"e.g Sales": "наприклад, Продажі",
"e.g Space for product team": "наприклад, Простір для продуктової команди",
"e.g Space for sales team to collaborate": "наприклад, Простір для спільної роботи команди продажів",
"Edit": "Редагувати",
"Edit group": "Редагувати групу",
"Email": "Електронна пошта",
"Enter a strong password": "Введіть надійний пароль",
"Enter valid email addresses separated by comma or space max_50": "Введіть дійсні адреси електронної пошти, розділені комою або пробілом [макс: 50]",
"enter valid emails addresses": "введіть дійсні адреси електронної пошти",
"Enter your current password": "Введіть ваш поточний пароль",
"enter your full name": "введіть ваше повне ім'я",
"Enter your new password": "Введіть ваш новий пароль",
"Enter your new preferred email": "Введіть вашу нову бажану електронну пошту",
"Enter your password": "Введіть ваш пароль",
"Error fetching page data.": "Помилка при завантаженні даних сторінки.",
"Error loading page history.": "Помилка при завантаженні історії сторінки.",
"Export": "Експорт",
"Failed to create page": "Не вдалося створити сторінку",
"Failed to delete page": "Не вдалося видалити сторінку",
"Failed to fetch recent pages": "Не вдалося отримати нещодавні сторінки",
"Failed to import pages": "Не вдалося імпортувати сторінки",
"Failed to load page. An error occurred.": "Не вдалося завантажити сторінку. Сталася помилка.",
"Failed to update data": "Не вдалося оновити дані",
"Full access": "Повний доступ",
"Full page width": "Ширина на всю сторінку",
"Full width": "На всю ширину",
"General": "Загальні",
"Group": "Група",
"Group description": "Опис групи",
"Group name": "Назва групи",
"Groups": "Групи",
"Has full access to space settings and pages.": "Має повний доступ до налаштувань простору та сторінок.",
"Home": "Головна",
"Import pages": "Імпорт сторінок",
"Import pages & space settings": "Імпорт сторінок і налаштування простору",
"Importing pages": "Імпортування сторінок",
"invalid invitation link": "посилання на запрошення недійсне",
"Invitation signup": "Реєстрація за запрошенням",
"Invite by email": "Запросити електронною поштою",
"Invite members": "Запросити учасників",
"Invite new members": "Запросити нових учасників",
"Invited members who are yet to accept their invitation will appear here.": "Запрошені учасники, які ще не прийняли запрошення, з'являться тут.",
"Invited members will be granted access to spaces the groups can access": "Запрошені учасники отримають доступ до просторів, доступ до яких має група",
"Join the workspace": "Приєднатися до робочої області",
"Language": "Мова",
"Light": "Світла",
"Link copied": "Посилання скопійовано",
"Login": "Увійти",
"Logout": "Вийти",
"Manage Group": "Керування групою",
"Manage members": "Керування учасниками",
"member": "учасник",
"Member": "Учасник",
"members": "учасники",
"Members": "Учасники",
"My preferences": "Мої налаштування",
"My Profile": "Мій профіль",
"My profile": "Мій профіль",
"Name": "Ім'я",
"New email": "Нова електронна адреса",
"New page": "Нова сторінка",
"New password": "Новий пароль",
"No group found": "Групу не знайдено",
"No page history saved yet.": "Історія сторінок ще не збережена.",
"No pages yet": "Сторінок поки немає",
"No results found...": "Результати не знайдено...",
"No user found": "Користувача не знайдено",
"Overview": "Огляд",
"Owner": "Власник",
"page": "сторінка",
"Page deleted successfully": "Сторінку успішно видалено",
"Page history": "Історія сторінки",
"Page import is in progress. Please do not close this tab.": "Імпорт сторінки в процесі. Будь ласка, не закривайте цю вкладку.",
"Pages": "Сторінки",
"pages": "сторінки",
"Password": "Пароль",
"Password changed successfully": "Пароль успішно змінено",
"Pending": "В очікуванні",
"Please confirm your action": "Будь ласка, підтвердіть вашу дію",
"Preferences": "Налаштування",
"Print PDF": "Друк PDF",
"Profile": "Профіль",
"Recently updated": "Нещодавно оновлено",
"Remove": "Видалити",
"Remove group member": "Видалити учасника групи",
"Remove space member": "Видалити учасника простору",
"Restore": "Відновити",
"Role": "Роль",
"Save": "Зберегти",
"Search": "Пошук",
"Search for groups": "Пошук груп",
"Search for users": "Пошук користувачів",
"Search for users and groups": "Пошук користувачів та груп",
"Search...": "Пошук...",
"Select language": "Оберіть мову",
"Select role": "Оберіть роль",
"Select role to assign to all invited members": "Оберіть роль для всіх запрошених учасників",
"Select theme": "Оберіть тему",
"Send invitation": "Надіслати запрошення",
"Invitation sent": "Запрошення надіслано",
"Settings": "Налаштування",
"Setup workspace": "Налаштувати робочу область",
"Sign In": "Вхід",
"Sign Up": "Реєстрація",
"Slug": "Slug",
"Space": "Простір",
"Space description": "Опис простору",
"Space menu": "Меню простору",
"Space name": "Назва простору",
"Space settings": "Налаштування простору",
"Space slug": "Slug простору",
"Spaces": "Простори",
"Spaces you belong to": "Простори, до яких ви належите",
"No space found": "Простори не знайдено",
"Search for spaces": "Пошук просторів",
"Start typing to search...": "Почніть вводити для пошуку...",
"Status": "Статус",
"Successfully imported": "Успішно імпортовано",
"Successfully restored": "Успішно відновлено",
"System settings": "Системні налаштування",
"Theme": "Тема",
"To change your email, you have to enter your password and new email.": "Щоб змінити електронну пошту, вам потрібно ввести пароль і нову адресу.",
"Toggle full page width": "Перемкнути ширину на всю сторінку",
"Unable to import pages. Please try again.": "Не вдалося імпортувати сторінки. Будь ласка, спробуйте ще раз.",
"untitled": "без назви",
"Untitled": "Без назви",
"Updated successfully": "Оновлено успішно",
"User": "Користувач",
"Workspace": "Робоча область",
"Workspace Name": "Ім'я робочої області",
"Workspace settings": "Налаштування робочої області",
"You can change your password here.": "Ви можете змінити свій пароль тут.",
"Your Email": "Ваша електронна пошта",
"Your import is complete.": "Ваш імпорт завершено.",
"Your name": "Ваше ім'я",
"Your Name": "Ваше ім'я",
"Your password": "Ваш пароль",
"Your password must be a minimum of 8 characters.": "Ваш пароль повинен містити мінімум 8 символів.",
"Sidebar toggle": "Перемкнути бічну панель",
"Comments": "Коментарі",
"404 page not found": "404 сторінку не знайдено",
"Sorry, we can't find the page you are looking for.": "На жаль, ми не можемо знайти сторінку, яку ви шукаєте.",
"Take me back to homepage": "Повернутися на головну сторінку",
"Forgot password": "Забули пароль",
"Forgot your password?": "Забули пароль?",
"A password reset link has been sent to your email. Please check your inbox.": "Посилання для скидання пароля було надіслано на вашу електронну адресу. Будь ласка, перевірте вхідні повідомлення.",
"Send reset link": "Надіслати посилання для скидання",
"Password reset": "Скидання пароля",
"Your new password": "Ваш новий пароль",
"Set password": "Встановити пароль",
"Write a comment": "Написати коментар",
"Reply...": "Відповісти...",
"Error loading comments.": "Помилка при завантаженні коментарів.",
"No comments yet.": "Коментарів поки немає.",
"Edit comment": "Редагувати коментар",
"Delete comment": "Видалити коментар",
"Are you sure you want to delete this comment?": "Ви впевнені, що хочете видалити цей коментар?",
"Comment created successfully": "Коментар успішно створено",
"Error creating comment": "Помилка при створенні коментаря",
"Comment updated successfully": "Коментар успішно оновлено",
"Failed to update comment": "Не вдалося оновити коментар",
"Comment deleted successfully": "Коментар успішно видалено",
"Failed to delete comment": "Не вдалося видалити коментар",
"Comment resolved successfully": "Коментар успішно вирішено",
"Failed to resolve comment": "Не вдалося вирішити коментар",
"Revoke invitation": "Відкликати запрошення",
"Revoke": "Відкликати",
"Don't": "Ні",
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Ви впевнені, що хочете відкликати це запрошення? Користувач не зможе приєднатися до робочої області.",
"Resend invitation": "Надіслати запрошення повторно",
"Anyone with this link can join this workspace.": "Будь-хто, хто має це посилання, може приєднатися до цієї робочої області.",
"Invite link": "Посилання для запрошення",
"Copy": "Копіювати",
"Copied": "Скопійовано",
"Select a user": "Оберіть користувача",
"Select a group": "Оберіть групу",
"Export all pages and attachments in this space.": "Експортувати всі сторінки та вкладення в цьому просторі.",
"Delete space": "Видалити простір",
"Are you sure you want to delete this space?": "Ви впевнені, що хочете видалити цей простір?",
"Delete this space with all its pages and data.": "Видалити цей простір з усіма його сторінками та даними.",
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Усі сторінки, коментарі, вкладення та дозволи в цьому просторі будуть видалені безповоротно.",
"Confirm space name": "Підтвердіть назву простору",
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Введіть назву простору <b>{{spaceName}}</b>, щоб підтвердити вашу дію.",
"Format": "Формат",
"Include subpages": "Включити вкладені сторінки",
"Include attachments": "Включити вкладення",
"Select export format": "Виберіть формат експорту",
"Export failed:": "Експортування не вдалося:",
"export error": "помилка експорту",
"Export page": "Експорт сторінки",
"Export space": "Експорт простору",
"Export {{type}}": "Експорт {{type}}",
"File exceeds the {{limit}} attachment limit": "Файл перевищує ліміт вкладень {{limit}}",
"Align left": "По лівому краю",
"Align right": "По правому краю",
"Align center": "По центру",
"Justify": "По ширині",
"Merge cells": "Об'єднати комірки",
"Split cell": "Розділити комірку",
"Delete column": "Видалити стовпець",
"Delete row": "Видалити рядок",
"Add left column": "Додати стовпець ліворуч",
"Add right column": "Додати стовпець праворуч",
"Add row above": "Додати рядок вище",
"Add row below": "Додати рядок нижче",
"Delete table": "Видалити таблицю",
"Info": "Інформація",
"Success": "Успішно",
"Warning": "Попередження",
"Danger": "Важливо",
"Mermaid diagram error:": "Помилка діаграми Mermaid:",
"Invalid Mermaid diagram": "Неприпустима діаграма Mermaid",
"Double-click to edit Draw.io diagram": "Клацніть двічі для редагування діаграми Draw.io",
"Exit": "Вийти",
"Save & Exit": "Зберегти та вийти",
"Double-click to edit Excalidraw diagram": "Клацніть двічі для редагування діаграми Excalidraw",
"Paste link": "Вставити посилання",
"Edit link": "Редагувати посилання",
"Remove link": "Видалити посилання",
"Add link": "Додати посилання",
"Please enter a valid url": "Будь ласка, введіть коректний url",
"Empty equation": "Порожнє рівняння",
"Invalid equation": "Неприпустиме рівняння",
"Color": "Колір",
"Text color": "Колір тексту",
"Default": "За замовчуванням",
"Blue": "Синій",
"Green": "Зелений",
"Purple": "Фіолетовий",
"Red": "Червоний",
"Yellow": "Жовтий",
"Orange": "Помаранчевий",
"Pink": "Рожевий",
"Gray": "Сірий",
"Embed link": "Вбудоване посилання",
"Invalid {{provider}} embed link": "Невірне посилання для вбудовування {{provider}}",
"Embed {{provider}}": "Вбудувати {{provider}}",
"Enter {{provider}} link to embed": "Введіть посилання для вбудовування {{provider}}",
"Bold": "Жирний",
"Italic": "Курсив",
"Underline": "Підкреслений",
"Strike": "Закреслений",
"Code": "Код",
"Comment": "Коментар",
"Text": "Текст",
"Heading 1": "Заголовок 1",
"Heading 2": "Заголовок 2",
"Heading 3": "Заголовок 3",
"To-do List": "Список справ",
"Bullet List": "Маркований список",
"Numbered List": "Нумерований список",
"Blockquote": "Блок цитування",
"Just start typing with plain text.": "Просто почніть друкувати звичайний текст.",
"Track tasks with a to-do list.": "Відстежуйте завдання за допомогою списку справ.",
"Big section heading.": "Великий заголовок розділу.",
"Medium section heading.": "Середній заголовок розділу.",
"Small section heading.": "Малий заголовок розділу.",
"Create a simple bullet list.": "Створити простий маркований список.",
"Create a list with numbering.": "Створити нумерований список.",
"Create block quote.": "Створити блок цитування.",
"Insert code snippet.": "Вставити фрагмент коду.",
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Table": "Таблиця",
"Insert a table.": "Вставити таблицю.",
"Insert collapsible block.": "Вставити блок, що згортається.",
"Video": "Відео",
"Divider": "Роздільник",
"Quote": "Цитата",
"Image": "Зображення",
"File attachment": "Прикріплений файл",
"Toggle block": "Блок, що згортається",
"Callout": "Виноска",
"Insert callout notice.": "Вставити виноску з повідомленням.",
"Math inline": "Формула",
"Insert inline math equation.": "Вставити математичне рівняння в рядок.",
"Math block": "Блок формул",
"Insert math equation": "Вставити математичне рівняння",
"Mermaid diagram": "Діаграма Mermaid",
"Insert mermaid diagram": "Вставити діаграму Mermaid",
"Insert and design Drawio diagrams": "Вставити та розробити діаграми Draw.io",
"Insert current date": "Вставити поточну дату",
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
"Multiple": "Декілька",
"Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Перемкнути заголовок",
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
"Names do not match": "Назви не співпадають",
"Today, {{time}}": "Сьогодні, {{time}}",
"Yesterday, {{time}}": "Вчора, {{time}}",
"Space created successfully": "Простір успішно створено",
"Space updated successfully": "Простір успішно оновлено",
"Space deleted successfully": "Простір успішно видалено",
"Members added successfully": "Учасників успішно додано",
"Member removed successfully": "Учасника успішно видалено",
"Member role updated successfully": "Роль учасника успішно оновлено",
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
"Created at: {{time}}": "Дата створення: {{time}}",
"Edited by {{name}} {{time}}": "Змінено {{name}} {{time}}",
"Word count: {{wordCount}}": "Кількість слів: {{wordCount}}",
"Character count: {{characterCount}}": "Кількість символів: {{characterCount}}",
"New update": "Нове оновлення",
"{{latestVersion}} is available": "Доступна нова версія {{latestVersion}}",
"Delete member": "Видалити учасника",
"Member deleted successfully": "Учасника успішно видалено",
"Are you sure you want to delete this workspace member? This action is irreversible.": "Ви впевнені, що хочете видалити цього учасника робочої області? Ця дія незворотна.",
"Move": "Перемістити",
"Move page": "Перемістити сторінку",
"Move page to a different space.": "Перемістити сторінку в інший простір.",
"Real-time editor connection lost. Retrying...": "З'єднання з редактором у реальному часі втрачено. Повторна спроба...",
"Table of contents": "Зміст",
"Add headings (H1, H2, H3) to generate a table of contents.": "Додайте заголовки (H1, H2, H3), щоб створити зміст.",
"Share": "Поділитися",
"Public sharing": "Публічний доступ",
"Shared by": "Поділився",
"Shared at": "Поділився в",
"Inherits public sharing from": "Успадковує публічний доступ від",
"Share to web": "Поділитися в інтернеті",
"Shared to web": "Розміщено в інтернеті",
"Anyone with the link can view this page": "Будь-хто, хто має посилання, може переглянути цю сторінку",
"Make this page publicly accessible": "Зробити цю сторінку загальнодоступною",
"Include sub-pages": "Включити підсторінки",
"Make sub-pages public too": "Зробити підсторінки також загальнодоступними",
"Allow search engines to index page": "Дозволити пошуковим системам індексувати сторінку",
"Open page": "Відкрити сторінку",
"Page": "Сторінка",
"Delete public share link": "Видалити посилання на публічний доступ",
"Delete share": "Видалити спільний доступ",
"Are you sure you want to delete this shared link?": "Ви впевнені, що хочете видалити це посилання спільного доступу?",
"Publicly shared pages from spaces you are a member of will appear here": "Публічні сторінки з просторів, учасником яких ви є, з'являться тут",
"Share deleted successfully": "Спільний доступ успішно видалено",
"Share not found": "Спільний доступ не знайдено",
"Failed to share page": "Не вдалося поділитися сторінкою",
"Copy page": "Копіювати сторінки",
"Copy page to a different space.": "Скопіювати сторінку в інший простір.",
"Page copied successfully": "Сторінку успішно скопійовано"
}

View File

@ -148,6 +148,7 @@
"Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色",
"Select theme": "选择主题",
"Send invitation": "发送邀请",
"Invitation sent": "邀请邮件已发送",
"Settings": "设置",
"Setup workspace": "设置工作空间",
"Sign In": "登录",
@ -244,6 +245,7 @@
"Align left": "靠左对齐",
"Align right": "靠右对齐",
"Align center": "居中对齐",
"Justify": "两端对齐",
"Merge cells": "合并单元格",
"Split cell": "分割单元格",
"Delete column": "删除整列",
@ -296,7 +298,7 @@
"Heading 2": "2 级标题",
"Heading 3": "3 级标题",
"To-do List": "代办列表",
"Bullet List": "无列表",
"Bullet List": "无列表",
"Numbered List": "有序列表",
"Blockquote": "引用块",
"Just start typing with plain text.": "只需开始键入纯文本",
@ -338,5 +340,51 @@
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
"Names do not match": "名称不匹配",
"Today, {{time}}": "今天,{{time}}",
"Yesterday, {{time}}": "昨天,{{time}}"
"Yesterday, {{time}}": "昨天,{{time}}",
"Space created successfully": "空间创建成功",
"Space updated successfully": "空间更新成功",
"Space deleted successfully": "空间已成功删除",
"Members added successfully": "成员添加成功",
"Member removed successfully": "成员移除成功",
"Member role updated successfully": "成员角色更新成功",
"Created by: <b>{{creatorName}}</b>": "创建者:<b>{{creatorName}}</b>",
"Created at: {{time}}": "创建于:{{time}}",
"Edited by {{name}} {{time}}": "由{{name}} 编辑于 {{time}}",
"Word count: {{wordCount}}": "字数:{{wordCount}}",
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
"New update": "新更新",
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
"Delete member": "删除成员",
"Member deleted successfully": "成员删除成功",
"Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。",
"Move": "移动",
"Move page": "移动页面",
"Move page to a different space.": "将页面移动到不同的空间。",
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
"Table of contents": "目录",
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题H1H2H3以生成目录。",
"Share": "分享",
"Public sharing": "公开分享",
"Shared by": "分享者",
"Shared at": "分享时间",
"Inherits public sharing from": "继承自的公开分享",
"Share to web": "分享到网页",
"Shared to web": "已分享到网页",
"Anyone with the link can view this page": "任何有链接的人都可以查看此页面",
"Make this page publicly accessible": "使此页面可公开访问",
"Include sub-pages": "包括子页面",
"Make sub-pages public too": "将子页面也设为公开",
"Allow search engines to index page": "允许搜索引擎索引页面",
"Open page": "打开页面",
"Page": "页面",
"Delete public share link": "删除公开分享链接",
"Delete share": "删除分享",
"Are you sure you want to delete this shared link?": "您确定要删除此分享链接吗?",
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
"Share deleted successfully": "分享已成功删除",
"Share not found": "未找到分享",
"Failed to share page": "页面分享失败",
"Copy page": "复制页面",
"Copy page to a different space.": "将页面复制到不同的空间。",
"Page copied successfully": "页面复制成功"
}

View File

@ -18,10 +18,24 @@ import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
import Billing from "@/ee/billing/pages/billing.tsx";
import CloudLogin from "@/ee/pages/cloud-login.tsx";
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
import { isCloud } from "@/lib/config.ts";
import { useTranslation } from "react-i18next";
import Security from "@/ee/security/pages/security.tsx";
import License from "@/ee/licence/pages/license.tsx";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import SharedPage from "@/pages/share/shared-page.tsx";
import Shares from "@/pages/settings/shares/shares.tsx";
import ShareLayout from "@/features/share/components/share-layout.tsx";
import ShareRedirect from '@/pages/share/share-redirect.tsx';
import { useTrackOrigin } from "@/hooks/use-track-origin";
export default function App() {
const { t } = useTranslation();
useRedirectToCloudSelect();
useTrackOrigin();
return (
<>
@ -29,15 +43,30 @@ export default function App() {
<Route index element={<Navigate to="/home" />} />
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/setup/register"} element={<SetupWorkspace />} />
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
{!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} />
)}
{isCloud() && (
<>
<Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} />
</>
)}
<Route element={<ShareLayout />}>
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
</Route>
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
<Route element={<Layout />}>
<Route path={"/home"} element={<Home />} />
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
<Route
path={"/s/:spaceSlug/p/:pageSlug"}
@ -61,6 +90,10 @@ export default function App() {
<Route path={"groups"} element={<Groups />} />
<Route path={"groups/:groupId"} element={<GroupInfo />} />
<Route path={"spaces"} element={<Spaces />} />
<Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} />
{!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route>
</Route>

View File

@ -0,0 +1,31 @@
import { ActionIcon, CopyButton, Tooltip } from "@mantine/core";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import React from "react";
import { useTranslation } from "react-i18next";
interface CopyProps {
text: string;
}
export default function CopyTextButton({ text }: CopyProps) {
const { t } = useTranslation();
return (
<CopyButton value={text} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? t("Copied") : t("Copy")}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
variant="subtle"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
}

View File

@ -65,11 +65,12 @@ export default function ExportModal({
yOffset="10vh"
xOffset={0}
mah={400}
onClick={(e) => e.stopPropagation()}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Export {type}</Modal.Title>
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>

View File

@ -21,7 +21,7 @@ export default function Paginate({
}
return (
<Group mt="md">
<Group mt="md" justify="flex-end">
<Button
variant="default"
size="compact-sm"

View File

@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function ConfluenceIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
</svg>
);
}

View File

@ -0,0 +1,33 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function GoogleIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
viewBox="0 0 256 262"
style={{ width: rem(size), height: rem(size) }}
>
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
/>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
/>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782"
/>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
export function OpenIdIcon({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M14.54.889l-3.63 1.773v18.17c-4.15-.52-7.27-2.78-7.27-5.5 0-2.58 2.8-4.75 6.63-5.41v-2.31C4.42 8.322 0 11.502 0 15.332c0 3.96 4.74 7.24 10.91 7.78l3.63-1.71V.888m.64 6.724v2.31c1.43.25 2.71.7 3.76 1.31l-1.97 1.11 7.03 1.53-.5-5.21-1.87 1.06c-1.74-1.06-3.96-1.81-6.45-2.11z" />
</svg>
);
}

View File

@ -1,19 +1,21 @@
import {Group, Text, Tooltip} from "@mantine/core";
import { Badge, Group, Text, Tooltip } from "@mantine/core";
import classes from "./app-header.module.css";
import React from "react";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import {Link} from "react-router-dom";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import {useAtom} from "jotai/index";
import { useAtom } from "jotai";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
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";
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
export function AppHeader() {
const { t } = useTranslation();
@ -22,6 +24,7 @@ export function AppHeader() {
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const { isTrial, trialDaysLeft } = useTrial();
const isHomeRoute = location.pathname.startsWith("/home");
@ -38,7 +41,6 @@ export function AppHeader() {
{!isHomeRoute && (
<>
<Tooltip label={t("Sidebar toggle")}>
<SidebarToggle
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
@ -63,7 +65,7 @@ export function AppHeader() {
<Text
size="lg"
fw={600}
style={{cursor: "pointer", userSelect: "none"}}
style={{ cursor: "pointer", userSelect: "none" }}
component={Link}
to="/home"
>
@ -75,8 +77,21 @@ export function AppHeader() {
</Group>
</Group>
<Group px={"xl"}>
<TopMenu/>
<Group px={"xl"} wrap="nowrap">
{isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge
variant="light"
style={{ cursor: "pointer" }}
component={Link}
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
visibleFrom="xs"
>
{trialDaysLeft === 1
? "1 day left"
: `${trialDaysLeft} days left`}
</Badge>
)}
<TopMenu />
</Group>
</Group>
</>

View File

@ -4,10 +4,14 @@ import { useAtom } from "jotai";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
import { useAtomValue } from "jotai";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
export default function Aside() {
const [{ tab }] = useAtom(asideStateAtom);
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
let title: string;
let component: ReactNode;
@ -17,6 +21,10 @@ export default function Aside() {
component = <CommentList />;
title = "Comments";
break;
case "toc":
component = <TableOfContents editor={pageEditor} />;
title = "Table of contents";
break;
default:
component = null;
title = null;

View File

@ -1,24 +1,29 @@
import { AppShell, Container } from "@mantine/core";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom, sidebarWidthAtom,
mobileSidebarAtom,
sidebarWidthAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
import Aside from "@/components/layouts/global/aside.tsx";
import classes from "./app-shell.module.css";
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
export default function GlobalAppShell({
children,
}: {
children: React.ReactNode;
}) {
useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
@ -37,7 +42,9 @@ export default function GlobalAppShell({
const resize = React.useCallback(
(mouseMoveEvent) => {
if (isResizing) {
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
const newWidth =
mouseMoveEvent.clientX -
sidebarRef.current.getBoundingClientRect().left;
if (newWidth < 220) {
setSidebarWidth(220);
return;
@ -49,7 +56,7 @@ export default function GlobalAppShell({
setSidebarWidth(newWidth);
}
},
[isResizing]
[isResizing],
);
useEffect(() => {
@ -94,7 +101,11 @@ export default function GlobalAppShell({
<AppHeader />
</AppShell.Header>
{!isHomeRoute && (
<AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
<AppShell.Navbar
className={classes.navbar}
withBorder={false}
ref={sidebarRef}
>
<div className={classes.resizeHandle} onMouseDown={startResizing} />
{isSpaceRoute && <SpaceSidebar />}
{isSettingsRoute && <SettingsSidebar />}
@ -102,7 +113,7 @@ export default function GlobalAppShell({
)}
<AppShell.Main>
{isSettingsRoute ? (
<Container size={800}>{children}</Container>
<Container size={850}>{children}</Container>
) : (
children
)}

View File

@ -20,4 +20,4 @@ export const asideStateAtom = atom<AsideStateType>({
isAsideOpen: false,
});
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);

View File

@ -33,13 +33,13 @@ export default function TopMenu() {
<UnstyledButton>
<Group gap={7} wrap={"nowrap"}>
<CustomAvatar
avatarUrl={workspace.logo}
name={workspace.name}
avatarUrl={workspace?.logo}
name={workspace?.name}
variant="filled"
size="sm"
/>
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace.name}
{workspace?.name}
</Text>
<IconChevronDown size={16} />
</Group>

View File

@ -0,0 +1,59 @@
import { useAppVersion } from "@/features/workspace/queries/workspace-query.ts";
import { isCloud } from "@/lib/config.ts";
import classes from "@/components/settings/settings.module.css";
import { Indicator, Text, Tooltip } from "@mantine/core";
import React from "react";
import semverGt from "semver/functions/gt";
import { useTranslation } from "react-i18next";
export default function AppVersion() {
const { t } = useTranslation();
const { data: appVersion } = useAppVersion(!isCloud());
let hasUpdate = false;
try {
hasUpdate =
appVersion &&
parseFloat(appVersion.latestVersion) > 0 &&
semverGt(appVersion.latestVersion, appVersion.currentVersion);
} catch (err) {
console.error(err);
}
return (
<div className={classes.text}>
<Tooltip
label={t("{{latestVersion}} is available", {
latestVersion: `v${appVersion?.latestVersion}`,
})}
disabled={!hasUpdate}
>
<Indicator
label={t("New update")}
color="gray"
inline
size={16}
position="middle-end"
style={{ cursor: "pointer" }}
disabled={!hasUpdate}
onClick={() => {
window.open(
"https://github.com/docmost/docmost/releases",
"_blank",
);
}}
>
<Text
size="sm"
c="dimmed"
component="a"
mr={45}
href="https://github.com/docmost/docmost/releases"
target="_blank"
>
v{APP_VERSION}
</Text>
</Indicator>
</Tooltip>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { atom, WritableAtom } from "jotai";
export const settingsOriginAtom: WritableAtom<string | null, [string | null], void> = atom(
null,
(get, set, newValue) => {
if (get(settingsOriginAtom) !== newValue) {
set(settingsOriginAtom, newValue);
}
}
);

View File

@ -0,0 +1,67 @@
import { queryClient } from "@/main.tsx";
import {
getBilling,
getBillingPlans,
} from "@/ee/billing/services/billing-service.ts";
import { getSpaces } from "@/features/space/services/space-service.ts";
import { getGroups } from "@/features/group/services/group-service.ts";
import { QueryParams } from "@/lib/types.ts";
import { getWorkspaceMembers } from "@/features/workspace/services/workspace-service.ts";
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";
export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
queryClient.prefetchQuery({
queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params),
});
};
export const prefetchSpaces = () => {
queryClient.prefetchQuery({
queryKey: ["spaces", { page: 1 }],
queryFn: () => getSpaces({ page: 1 }),
});
};
export const prefetchGroups = () => {
queryClient.prefetchQuery({
queryKey: ["groups", { page: 1 }],
queryFn: () => getGroups({ page: 1 }),
});
};
export const prefetchBilling = () => {
queryClient.prefetchQuery({
queryKey: ["billing"],
queryFn: () => getBilling(),
});
queryClient.prefetchQuery({
queryKey: ["billing-plans"],
queryFn: () => getBillingPlans(),
});
};
export const prefetchLicense = () => {
queryClient.prefetchQuery({
queryKey: ["license"],
queryFn: () => getLicenseInfo(),
});
};
export const prefetchSsoProviders = () => {
queryClient.prefetchQuery({
queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(),
});
};
export const prefetchShares = () => {
queryClient.prefetchQuery({
queryKey: ["share-list", { page: 1 }],
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
import {
IconUser,
IconSettings,
@ -8,15 +8,40 @@ import {
IconUsersGroup,
IconSpaces,
IconBrush,
IconCoin,
IconLock,
IconKey,
IconWorld,
} from "@tabler/icons-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
prefetchBilling,
prefetchGroups,
prefetchLicense,
prefetchShares,
prefetchSpaces,
prefetchSsoProviders,
prefetchWorkspaceMembers,
} from "@/components/settings/settings-queries.tsx";
import AppVersion from "@/components/settings/app-version.tsx";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
interface DataItem {
label: string;
icon: React.ElementType;
path: string;
isCloud?: boolean;
isEnterprise?: boolean;
isAdmin?: boolean;
isSelfhosted?: boolean;
}
interface DataGroup {
@ -45,8 +70,34 @@ const groupedData: DataGroup[] = [
icon: IconUsers,
path: "/settings/members",
},
{
label: "Billing",
icon: IconCoin,
path: "/settings/billing",
isCloud: true,
isAdmin: true,
},
{
label: "Security & SSO",
icon: IconLock,
path: "/settings/security",
isCloud: true,
isEnterprise: true,
isAdmin: true,
},
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
],
},
{
heading: "System",
items: [
{
label: "License & Edition",
icon: IconKey,
path: "/settings/license",
},
],
},
];
@ -55,36 +106,117 @@ export default function SettingsSidebar() {
const { t } = useTranslation();
const location = useLocation();
const [active, setActive] = useState(location.pathname);
const navigate = useNavigate();
const { goBack } = useSettingsNavigation();
const { isAdmin } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
useEffect(() => {
setActive(location.pathname);
}, [location.pathname]);
const menuItems = groupedData.map((group) => (
<div key={group.heading}>
<Text c="dimmed" className={classes.linkHeader}>
{t(group.heading)}
</Text>
{group.items.map((item) => (
<Link
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
))}
</div>
));
const canShowItem = (item: DataItem) => {
if (item.isCloud && item.isEnterprise) {
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
return item.isAdmin ? isAdmin : true;
}
if (item.isCloud) {
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isSelfhosted) {
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isEnterprise) {
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
}
if (item.isAdmin) {
return isAdmin;
}
return true;
};
const menuItems = groupedData.map((group) => {
if (group.heading === "System" && (!isAdmin || isCloud())) {
return null;
}
return (
<div key={group.heading}>
<Text c="dimmed" className={classes.linkHeader}>
{t(group.heading)}
</Text>
{group.items.map((item) => {
if (!canShowItem(item)) {
return null;
}
let prefetchHandler: any;
switch (item.label) {
case "Members":
prefetchHandler = prefetchWorkspaceMembers;
break;
case "Spaces":
prefetchHandler = prefetchSpaces;
break;
case "Groups":
prefetchHandler = prefetchGroups;
break;
case "Billing":
prefetchHandler = prefetchBilling;
break;
case "License & Edition":
if (workspace?.hasLicenseKey) {
prefetchHandler = prefetchLicense;
}
break;
case "Security & SSO":
prefetchHandler = prefetchSsoProviders;
break;
case "Public sharing":
prefetchHandler = prefetchShares;
break;
default:
break;
}
return (
<Link
onMouseEnter={prefetchHandler}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
})}
</div>
);
});
return (
<div className={classes.navbar}>
<Group className={classes.title} justify="flex-start">
<ActionIcon
onClick={() => navigate(-1)}
onClick={() => {
goBack();
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
variant="transparent"
c="gray"
aria-label="Back"
@ -95,18 +227,21 @@ export default function SettingsSidebar() {
</Group>
<ScrollArea w="100%">{menuItems}</ScrollArea>
<div className={classes.version}>
<Text
className={classes.version}
size="sm"
c="dimmed"
component="a"
href="https://github.com/docmost/docmost/releases"
target="_blank"
>
v{APP_VERSION}
</Text>
</div>
{!isCloud() && <AppVersion />}
{isCloud() && (
<div className={classes.text}>
<Text
size="sm"
c="dimmed"
component="a"
href="mailto:help@docmost.com"
>
help@docmost.com
</Text>
</div>
)}
</div>
);
}

View File

@ -58,7 +58,7 @@
align-items: center;
}
.version {
.text {
padding-left: var(--mantine-spacing-xs) ;
padding-top: 10px;
}

View File

@ -0,0 +1,19 @@
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@ -1,13 +1,28 @@
import { Button, Group, useMantineColorScheme } from '@mantine/core';
import {
ActionIcon,
Tooltip,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core";
import { IconMoon, IconSun } from "@tabler/icons-react";
import classes from "./theme-toggle.module.css";
export function ThemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme();
return (
<Group justify="center" mt="xl">
<Button onClick={() => setColorScheme('light')}>Light</Button>
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
</Group>
);
return (
<Tooltip label="Toggle Color Scheme">
<ActionIcon
variant="default"
onClick={() => {
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
}}
aria-label="Toggle color scheme"
>
<IconSun className={classes.light} size={18} stroke={1.5} />
<IconMoon className={classes.dark} size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
}

View File

@ -68,8 +68,8 @@ function EmojiPicker({
{icon}
</ActionIcon>
</Popover.Target>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Suspense fallback={null}>
<Suspense fallback={null}>
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
<Picker
data={async () => (await import("@emoji-mart/data")).default}
onEmojiSelect={handleEmojiSelect}
@ -77,22 +77,22 @@ function EmojiPicker({
skinTonePosition="search"
theme={colorScheme}
/>
</Suspense>
<Button
variant="default"
c="gray"
size="xs"
style={{
position: "absolute",
zIndex: 2,
bottom: "1rem",
right: "1rem",
}}
onClick={handleRemoveEmoji}
>
{t("Remove")}
</Button>
</Popover.Dropdown>
<Button
variant="default"
c="gray"
size="xs"
style={{
position: "absolute",
zIndex: 2,
bottom: "1rem",
right: "1rem",
}}
onClick={handleRemoveEmoji}
>
{t("Remove")}
</Button>
</Popover.Dropdown>
</Suspense>
</Popover>
);
}

View File

@ -0,0 +1 @@
Files in this directory are subject to the Docmost Enterprise Edition license.

View File

@ -0,0 +1,170 @@
import {
useBillingPlans,
useBillingQuery,
} from "@/ee/billing/queries/billing-query.ts";
import { Group, Text, SimpleGrid, Paper } from "@mantine/core";
import classes from "./billing.module.css";
import { format } from "date-fns";
import { formatInterval } from "@/ee/billing/utils.ts";
export default function BillingDetails() {
const { data: billing } = useBillingQuery();
const { data: plans } = useBillingPlans();
if (!billing || !plans) {
return null;
}
return (
<div className={classes.root}>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Plan
</Text>
<Text fw={700} fz="lg">
{
plans.find(
(plan) => plan.productId === billing.stripeProductId,
)?.name
}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Billing Period
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{formatInterval(billing.interval)}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
{billing.cancelAtPeriodEnd
? "Cancellation date"
: "Renewal date"}
</Text>
<Text fw={700} fz="lg">
{format(billing.periodEndAt, "dd MMM, yyyy")}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3 }}>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Seat count
</Text>
<Text fw={700} fz="lg">
{billing.quantity}
</Text>
</div>
</Group>
</Paper>
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Cost
</Text>
{billing.billingScheme === "tiered" && (
<>
<Text fw={700} fz="lg">
${billing.amount / 100} {billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
per {billing.interval}
</Text>
</>
)}
{billing.billingScheme !== "tiered" && (
<>
<Text fw={700} fz="lg">
{(billing.amount / 100) * billing.quantity}{" "}
{billing.currency.toUpperCase()}
</Text>
<Text c="dimmed" fz="sm">
${billing.amount / 100} /user/{billing.interval}
</Text>
</>
)}
</div>
</Group>
</Paper>
{billing.billingScheme === "tiered" && billing.tieredUpTo && (
<Paper p="md" radius="md">
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Current Tier
</Text>
<Text fw={700} fz="lg">
For up to {billing.tieredUpTo} users
</Text>
{/*billing.tieredFlatAmount && (
<Text c="dimmed" fz="sm">
</Text>
)*/}
</div>
</Group>
</Paper>
)}
</SimpleGrid>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Alert } from "@mantine/core";
import React from "react";
export default function BillingIncomplete() {
return (
<>
<Alert variant="light" color="blue">
Your subscription is in an incomplete state. Please refresh this page if
you recently made your payment.
</Alert>
</>
);
}

View File

@ -0,0 +1,188 @@
import {
Button,
Card,
List,
ThemeIcon,
Title,
Text,
Group,
Select,
Container,
Stack,
Badge,
Flex,
Switch,
} from "@mantine/core";
import { useState } from "react";
import { IconCheck } from "@tabler/icons-react";
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
export default function BillingPlans() {
const { data: plans } = useBillingPlans();
const [isAnnual, setIsAnnual] = useState(true);
const [selectedTierValue, setSelectedTierValue] = useState<string | null>(
null,
);
const handleCheckout = async (priceId: string) => {
try {
const checkoutLink = await getCheckoutLink({
priceId: priceId,
});
window.location.href = checkoutLink.url;
} catch (err) {
console.error("Failed to get checkout link", err);
}
};
if (!plans || plans.length === 0) {
return null;
}
const firstPlan = plans[0];
// Set initial tier value if not set
if (!selectedTierValue && firstPlan.pricingTiers.length > 0) {
setSelectedTierValue(firstPlan.pricingTiers[0].upTo.toString());
return null;
}
if (!selectedTierValue) {
return null;
}
const selectData = firstPlan.pricingTiers
.filter((tier) => !tier.custom)
.map((tier, index) => {
const prevMaxUsers =
index > 0 ? firstPlan.pricingTiers[index - 1].upTo : 0;
return {
value: tier.upTo.toString(),
label: `${prevMaxUsers + 1}-${tier.upTo} users`,
};
});
return (
<Container size="xl" py="xl">
{/* Controls Section */}
<Stack gap="xl" mb="md">
{/* Team Size and Billing Controls */}
<Group justify="center" align="center" gap="sm">
<Select
label="Team size"
description="Select the number of users"
value={selectedTierValue}
onChange={setSelectedTierValue}
data={selectData}
w={250}
size="md"
allowDeselect={false}
/>
<Group justify="center" align="start">
<Flex justify="center" gap="md" align="center">
<Text size="md">Monthly</Text>
<Switch
defaultChecked={isAnnual}
onChange={(event) => setIsAnnual(event.target.checked)}
size="sm"
/>
<Text size="md">
Annually
<Badge component="span" variant="light" color="blue">
15% OFF
</Badge>
</Text>
</Flex>
</Group>
</Group>
</Stack>
{/* Plans Grid */}
<Group justify="center" gap="lg" align="stretch">
{plans.map((plan, index) => {
const tieredPlan = plan;
const planSelectedTier =
tieredPlan.pricingTiers.find(
(tier) => tier.upTo.toString() === selectedTierValue,
) || tieredPlan.pricingTiers[0];
const price = isAnnual
? planSelectedTier.yearly
: planSelectedTier.monthly;
const priceId = isAnnual ? plan.yearlyId : plan.monthlyId;
return (
<Card
key={plan.name}
withBorder
radius="lg"
shadow="sm"
p="xl"
w={350}
miw={300}
style={{
position: "relative",
}}
>
<Stack gap="lg">
{/* Plan Header */}
<Stack gap="xs">
<Title order={3} size="h4">
{plan.name}
</Title>
{plan.description && (
<Text size="sm" c="dimmed">
{plan.description}
</Text>
)}
</Stack>
{/* Pricing */}
<Stack gap="xs">
<Group align="baseline" gap="xs">
<Title order={1} size="h1">
${isAnnual ? (price / 12).toFixed(0) : price}
</Title>
<Text size="lg" c="dimmed">
per {isAnnual ? "month" : "month"}
</Text>
</Group>
{isAnnual && (
<Text size="sm" c="dimmed">
Billed annually
</Text>
)}
<Text size="md" fw={500}>
for up to {planSelectedTier.upTo} users
</Text>
</Stack>
{/* CTA Button */}
<Button onClick={() => handleCheckout(priceId)} fullWidth>
Upgrade
</Button>
{/* Features */}
<List
spacing="xs"
size="sm"
icon={
<ThemeIcon size={20} radius="xl">
<IconCheck size={14} />
</ThemeIcon>
}
>
{plan.features.map((feature, featureIndex) => (
<List.Item key={featureIndex}>{feature}</List.Item>
))}
</List>
</Stack>
</Card>
);
})}
</Group>
</Container>
);
}

View File

@ -0,0 +1,32 @@
import { Alert } from "@mantine/core";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useTrial from "@/ee/hooks/use-trial.tsx";
import { getBillingTrialDays } from '@/lib/config.ts';
export default function BillingTrial() {
const { data: billing, isLoading } = useBillingQuery();
const { trialDaysLeft } = useTrial();
if (isLoading) {
return null;
}
return (
<>
{trialDaysLeft > 0 && !billing && (
<Alert title="Your Trial is Active 🎉" color="blue" radius="md">
You have {trialDaysLeft} {trialDaysLeft === 1 ? "day" : "days"} left
in your {getBillingTrialDays()}-day free trial. Please subscribe to a paid plan before your trial
ends.
</Alert>
)}
{trialDaysLeft === 0 && (
<Alert title="Your Trial has ended" color="red" radius="md">
Your {getBillingTrialDays()}-day free trial has come to an end. Please subscribe to a paid plan to
continue using this service.
</Alert>
)}
</>
);
}

View File

@ -0,0 +1,10 @@
.root {
padding-top: var(--mantine-spacing-xs);
padding-bottom: var(--mantine-spacing-xs);
}
.label {
font-family:
Greycliff CF,
var(--mantine-font-family);
}

View File

@ -0,0 +1,34 @@
import { Button, Group, Text } from "@mantine/core";
import React from "react";
import { getBillingPortalLink } from "@/ee/billing/services/billing-service.ts";
export default function ManageBilling() {
const handleBillingPortal = async () => {
try {
const portalLink = await getBillingPortalLink();
window.location.href = portalLink.url;
} catch (err) {
console.error("Failed to get billing portal link", err);
}
};
return (
<>
<Group justify="space-between" wrap="wrap" gap="xl">
<div style={{ flex: 1, minWidth: "200px" }}>
<Text size="md" fw={500}>
Manage subscription
</Text>
<Text size="sm" c="dimmed">
Manage your your subscription, invoices, update payment details, and
more.
</Text>
</div>
<Button style={{ flexShrink: 0 }} onClick={handleBillingPortal}>
Manage
</Button>
</Group>
</>
);
}

View File

@ -0,0 +1,41 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import BillingPlans from "@/ee/billing/components/billing-plans.tsx";
import BillingTrial from "@/ee/billing/components/billing-trial.tsx";
import ManageBilling from "@/ee/billing/components/manage-billing.tsx";
import { Divider } from "@mantine/core";
import React from "react";
import BillingDetails from "@/ee/billing/components/billing-details.tsx";
import { useBillingQuery } from "@/ee/billing/queries/billing-query.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
export default function Billing() {
const { data: billing, isError: isBillingError } = useBillingQuery();
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>Billing - {getAppName()}</title>
</Helmet>
<SettingsTitle title="Billing" />
<BillingTrial />
<BillingDetails />
{isBillingError && <BillingPlans />}
{billing && (
<>
<Divider my="lg" />
<ManageBilling />
</>
)}
</>
);
}

View File

@ -0,0 +1,20 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import {
getBilling,
getBillingPlans,
} from "@/ee/billing/services/billing-service.ts";
import { IBilling, IBillingPlan } from "@/ee/billing/types/billing.types.ts";
export function useBillingQuery(): UseQueryResult<IBilling, Error> {
return useQuery({
queryKey: ["billing"],
queryFn: () => getBilling(),
});
}
export function useBillingPlans(): UseQueryResult<IBillingPlan[], Error> {
return useQuery({
queryKey: ["billing-plans"],
queryFn: () => getBillingPlans(),
});
}

View File

@ -0,0 +1,29 @@
import api from "@/lib/api-client.ts";
import {
IBilling,
IBillingPlan,
IBillingPortal,
ICheckoutLink,
} from "@/ee/billing/types/billing.types.ts";
export async function getBilling(): Promise<IBilling> {
const req = await api.post<IBilling>("/billing/info");
return req.data;
}
export async function getBillingPlans(): Promise<IBillingPlan[]> {
const req = await api.post<IBillingPlan[]>("/billing/plans");
return req.data;
}
export async function getCheckoutLink(data: {
priceId: string;
}): Promise<ICheckoutLink> {
const req = await api.post<ICheckoutLink>("/billing/checkout", data);
return req.data;
}
export async function getBillingPortalLink(): Promise<IBillingPortal> {
const req = await api.post<IBillingPortal>("/billing/portal");
return req.data;
}

View File

@ -0,0 +1,64 @@
export enum BillingPlan {
STANDARD = "standard",
BUSINESS = "business",
}
export interface IBilling {
id: string;
stripeSubscriptionId: string;
stripeCustomerId: string;
status: string;
quantity: number;
amount: number;
interval: string;
currency: string;
metadata: Record<string, any>;
stripePriceId: string;
stripeItemId: string;
stripeProductId: string;
periodStartAt: Date;
periodEndAt: Date;
cancelAtPeriodEnd: boolean;
cancelAt: Date;
canceledAt: Date;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
billingScheme: string | null;
tieredUpTo: string | null;
tieredFlatAmount: number | null;
tieredUnitAmount: number | null;
planName: string | null;
}
export interface ICheckoutLink {
url: string;
}
export interface IBillingPortal {
url: string;
}
export interface IBillingPlan {
name: string;
description: string;
productId: string;
monthlyId: string;
yearlyId: string;
currency: string;
price?: {
monthly: string;
yearly: string;
};
features: string[];
billingScheme: string | null;
pricingTiers: PricingTier[];
}
interface PricingTier {
upTo: number;
monthly?: number;
yearly?: number;
custom?: boolean;
}

View File

@ -0,0 +1,17 @@
import { differenceInCalendarDays } from "date-fns";
export function formatInterval(interval: string): string {
if (interval === "month") {
return "monthly";
}
if (interval === "year") {
return "yearly";
}
}
export function getTrialDaysLeft(trialEndAt: Date) {
if (!trialEndAt) return null;
const daysLeft = differenceInCalendarDays(trialEndAt, new Date());
return daysLeft > 0 ? daysLeft : 0;
}

View File

@ -0,0 +1,13 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { getJoinedWorkspaces } from "@/ee/cloud/service/cloud-service.ts";
export function useJoinedWorkspacesQuery(): UseQueryResult<
Partial<IWorkspace[]>,
Error
> {
return useQuery({
queryKey: ["joined-workspaces"],
queryFn: () => getJoinedWorkspaces(),
});
}

View File

@ -0,0 +1,7 @@
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import api from "@/lib/api-client.ts";
export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data;
}

View File

@ -0,0 +1,96 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
Title,
TextInput,
Button,
Box,
Text,
Anchor,
Divider,
} from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import { getCheckHostname } from "@/features/workspace/services/workspace-service.ts";
import { useState } from "react";
import { getSubdomainHost } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }),
});
export function CloudLoginForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({
validate: zodResolver(formSchema),
initialValues: {
hostname: "",
},
});
async function onSubmit(data: { hostname: string }) {
setIsLoading(true);
try {
const checkHostname = await getCheckHostname(data.hostname);
window.location.href = checkHostname.hostname;
} catch (err) {
if (err?.status === 404) {
form.setFieldError("hostname", "We could not find this workspace");
} else {
form.setFieldError("hostname", "An error occurred");
}
}
setIsLoading(false);
}
return (
<div>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Login")}
</Title>
<JoinedWorkspaces />
{joinedWorkspaces?.length > 0 && (
<Divider my="xs" label="OR" labelPosition="center" />
)}
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
type="text"
placeholder="my-team"
description="Enter your workspace hostname"
label="Workspace hostname"
rightSection={<Text fw={500}>.{getSubdomainHost()}</Text>}
rightSectionWidth={150}
withErrorStyles={false}
{...form.getInputProps("hostname")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Continue")}
</Button>
</form>
</Box>
</Container>
<Text ta="center">
{t("Don't have a workspace?")}{" "}
<Anchor component={Link} to={APP_ROUTE.AUTH.CREATE_WORKSPACE} fw={500}>
{t("Create new workspace")}
</Anchor>
</Text>
</div>
);
}

View File

@ -0,0 +1,13 @@
.workspace {
display: block;
width: 100%;
padding: var(--mantine-spacing-xs);
margin-bottom: var(--mantine-spacing-xs);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
border-radius: var(--mantine-spacing-xs);
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
}

View File

@ -0,0 +1,51 @@
import { Group, Text, UnstyledButton } from "@mantine/core";
import { useJoinedWorkspacesQuery } from "../cloud/query/cloud-query";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import classes from "./joined-workspaces.module.css";
import { IconChevronRight } from "@tabler/icons-react";
import { getHostnameUrl } from "@/ee/utils.ts";
import { Link } from "react-router-dom";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
export default function JoinedWorkspaces() {
const { data, isLoading } = useJoinedWorkspacesQuery();
if (isLoading || !data || data?.length === 0) {
return null;
}
return (
<>
{data
.sort((a, b) => a.name.localeCompare(b.name))
.map((workspace: Partial<IWorkspace>, index) => (
<UnstyledButton
key={index}
component={Link}
to={getHostnameUrl(workspace?.hostname) + "/home"}
className={classes.workspace}
>
<Group wrap="nowrap">
<CustomAvatar
avatarUrl={workspace?.logo}
name={workspace?.name}
variant="filled"
size="md"
/>
<div style={{ flex: 1 }}>
<Text size="sm" fw={500} lineClamp={1}>
{workspace?.name}
</Text>
<Text c="dimmed" size="sm">
{getHostnameUrl(workspace?.hostname)?.split("//")[1]}
</Text>
</div>
<IconChevronRight size={16} />
</Group>
</UnstyledButton>
))}
</>
);
}

View File

@ -0,0 +1,119 @@
import { Button, Group, Text, Modal, TextInput } from "@mantine/core";
import * as z from "zod";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import * as React from "react";
import { useForm, zodResolver } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { getSubdomainHost } from "@/lib/config.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { getHostnameUrl } from "@/ee/utils.ts";
import { useAtom } from "jotai/index";
import {
currentUserAtom,
workspaceAtom,
} from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { RESET } from "jotai/utils";
export default function ManageHostname() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Hostname")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{workspace?.hostname}.{getSubdomainHost()}
</Text>
</div>
{isAdmin && (
<Button onClick={open} variant="default">
{t("Change hostname")}
</Button>
)}
<Modal
opened={opened}
onClose={close}
title={t("Change hostname")}
centered
>
<ChangeHostnameForm onClose={close} />
</Modal>
</Group>
);
}
const formSchema = z.object({
hostname: z.string().min(4),
});
type FormValues = z.infer<typeof formSchema>;
interface ChangeHostnameFormProps {
onClose?: () => void;
}
function ChangeHostnameForm({ onClose }: ChangeHostnameFormProps) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [currentUser, setCurrentUser] = useAtom(currentUserAtom);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
hostname: currentUser?.workspace?.hostname,
},
});
async function handleSubmit(data: Partial<IWorkspace>) {
setIsLoading(true);
if (data.hostname === currentUser?.workspace?.hostname) {
onClose();
return;
}
try {
await updateWorkspace({
hostname: data.hostname,
});
setCurrentUser(RESET);
window.location.href = getHostnameUrl(data.hostname.toLowerCase());
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
setIsLoading(false);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
type="text"
placeholder="e.g my-team"
label="Hostname"
variant="filled"
rightSection={<Text fw={500}>.{getSubdomainHost()}</Text>}
rightSectionWidth={150}
withErrorStyles={false}
width={200}
{...form.getInputProps("hostname")}
/>
<Group justify="flex-end" mt="md">
<Button type="submit" disabled={isLoading} loading={isLoading}>
{t("Change hostname")}
</Button>
</Group>
</form>
);
}

View File

@ -0,0 +1,25 @@
import { Button, Divider, Stack } from "@mantine/core";
import { getGoogleSignupUrl } from "@/ee/security/sso.utils.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
export default function SsoCloudSignup() {
const handleSsoLogin = () => {
window.location.href = getGoogleSignupUrl();
};
return (
<>
<Stack align="stretch" justify="center" gap="sm">
<Button
onClick={handleSsoLogin}
leftSection={<GoogleIcon size={16} />}
variant="default"
fullWidth
>
Signup with Google
</Button>
</Stack>
<Divider my="xs" label="OR" labelPosition="center" />
</>
);
}

View File

@ -0,0 +1,57 @@
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock } 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";
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
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,
});
};
return (
<>
{(isCloud() || data.hasLicenseKey) && (
<>
<Stack align="stretch" justify="center" gap="sm">
{data.authProviders.map((provider) => (
<div key={provider.id}>
<Button
onClick={() => handleSsoLogin(provider)}
leftSection={
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
variant="default"
fullWidth
>
{provider.name}
</Button>
</div>
))}
</Stack>
{!data.enforceSso && (
<Divider my="xs" label="OR" labelPosition="center" />
)}
</>
)}
</>
);
}

View File

@ -0,0 +1,9 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
export const useLicense = () => {
const [currentUser] = useAtom(currentUserAtom);
return { hasLicenseKey: currentUser?.workspace?.hasLicenseKey };
};
export default useLicense;

View File

@ -0,0 +1,19 @@
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { BillingPlan } from "@/ee/billing/types/billing.types.ts";
const usePlan = () => {
const [workspace] = useAtom(workspaceAtom);
const isStandard =
typeof workspace?.plan === "string" &&
workspace?.plan.toLowerCase() === BillingPlan.STANDARD.toLowerCase();
const isBusiness =
typeof workspace?.plan === "string" &&
workspace?.plan.toLowerCase() === BillingPlan.BUSINESS.toLowerCase();
return { isStandard, isBusiness };
};
export default usePlan;

View File

@ -0,0 +1,20 @@
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { getAppUrl, getServerAppUrl, isCloud } from "@/lib/config.ts";
import APP_ROUTE from "@/lib/app-route.ts";
export const useRedirectToCloudSelect = () => {
const navigate = useNavigate();
const pathname = useLocation().pathname;
useEffect(() => {
const pathsToRedirect = ["/login", "/home"];
if (isCloud() && pathsToRedirect.includes(pathname)) {
const frontendUrl = getAppUrl();
const serverUrl = getServerAppUrl();
if (frontendUrl === serverUrl) {
navigate(APP_ROUTE.AUTH.SELECT_WORKSPACE);
}
}
}, [navigate]);
};

View File

@ -0,0 +1,36 @@
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { getBillingTrialDays, isCloud } from "@/lib/config.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { notifications } from "@mantine/notifications";
import useTrial from "@/ee/hooks/use-trial.tsx";
export const useTrialEndAction = () => {
const navigate = useNavigate();
const pathname = useLocation().pathname;
const { isAdmin } = useUserRole();
const { trialDaysLeft } = useTrial();
useEffect(() => {
if (isCloud() && trialDaysLeft === 0) {
if (!pathname.startsWith("/settings")) {
notifications.show({
position: "top-right",
color: "red",
title: `Your ${getBillingTrialDays()}-day trial has ended`,
message:
"Please upgrade to a paid plan or contact your workspace admin.",
autoClose: false,
});
// only admins can access the billing page
if (isAdmin) {
navigate(APP_ROUTE.SETTINGS.WORKSPACE.BILLING);
} else {
navigate(APP_ROUTE.SETTINGS.ACCOUNT.PROFILE);
}
}
}
}, [navigate]);
};

View File

@ -0,0 +1,16 @@
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { getTrialDaysLeft } from "@/ee/billing/utils.ts";
import { ICurrentUser } from "@/features/user/types/user.types.ts";
export const useTrial = () => {
const [currentUser] = useAtom<ICurrentUser>(currentUserAtom);
const workspace = currentUser?.workspace;
const trialDaysLeft = getTrialDaysLeft(workspace?.trialEndAt);
const isTrial = !!workspace?.trialEndAt && trialDaysLeft !== null;
return { isTrial: isTrial, trialDaysLeft: trialDaysLeft };
};
export default useTrial;

View File

@ -0,0 +1,89 @@
import * as z from "zod";
import React from "react";
import { Button, Group, Modal, Textarea } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useTranslation } from "react-i18next";
import { useActivateMutation } from "@/ee/licence/queries/license-query.ts";
import { useDisclosure } from "@mantine/hooks";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import RemoveLicense from "@/ee/licence/components/remove-license.tsx";
export default function ActivateLicense() {
const { t } = useTranslation();
const [opened, { open, close }] = useDisclosure(false);
const [workspace] = useAtom(workspaceAtom);
return (
<Group justify="flex-end" wrap="nowrap" mb="sm">
<Button onClick={open}>
{workspace?.hasLicenseKey ? t("Update license") : t("Add license")}
</Button>
{workspace?.hasLicenseKey && <RemoveLicense />}
<Modal
size="550"
opened={opened}
onClose={close}
title={t("Enterprise license")}
centered
>
<ActivateLicenseForm onClose={close} />
</Modal>
</Group>
);
}
const formSchema = z.object({
licenseKey: z.string().min(1),
});
type FormValues = z.infer<typeof formSchema>;
interface ActivateLicenseFormProps {
onClose?: () => void;
}
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
const { t } = useTranslation();
const activateLicenseMutation = useActivateMutation();
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
licenseKey: "",
},
});
async function handleSubmit(data: { licenseKey: string }) {
await activateLicenseMutation.mutateAsync(data.licenseKey);
form.reset();
onClose();
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Textarea
label={t("License key")}
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one."
placeholder={t("e.g eyJhb.....")}
variant="filled"
autosize
minRows={3}
maxRows={5}
data-autofocus
{...form.getInputProps("licenseKey")}
/>
<Group justify="flex-end" mt="md">
<Button
type="submit"
disabled={activateLicenseMutation.isPending}
loading={activateLicenseMutation.isPending}
>
{t("Save")}
</Button>
</Group>
</form>
);
}

View File

@ -0,0 +1,71 @@
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import classes from "@/ee/billing/components/billing.module.css";
import {
Group,
Paper,
SimpleGrid,
Text,
TextInput,
} from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import CopyTextButton from "@/components/common/copy.tsx";
export default function InstallationDetails() {
const { isAdmin } = useUserRole();
const [workspace] = useAtom(workspaceAtom);
if (!isAdmin) {
return null;
}
return (
<>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 2 }}>
<Paper p="sm" radius="md" withBorder={true}>
<Group justify="apart" grow>
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Workspace ID
</Text>
<TextInput
style={{ fontWeight: 700 }}
variant="unstyled"
readOnly
value={workspace?.id}
pointer
rightSection={<CopyTextButton text={workspace?.id} />}
/>
</div>
</Group>
</Paper>
<Paper p="md" radius="md" withBorder={true}>
<Group justify="apart">
<div>
<Text
c="dimmed"
tt="uppercase"
fw={700}
fz="xs"
className={classes.label}
>
Member count
</Text>
<Text fw={700} fz="lg" tt="capitalize">
{workspace?.memberCount}
</Text>
</div>
</Group>
</Paper>
</SimpleGrid>
</>
);
}

View File

@ -0,0 +1,81 @@
import { Badge, Table } from "@mantine/core";
import { format } from "date-fns";
import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts";
import { isLicenseExpired } from "@/ee/licence/license.utils.ts";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function LicenseDetails() {
const { data: license, isError } = useLicenseInfo();
const [workspace] = useAtom(workspaceAtom);
if (!license) {
return null;
}
if (isError) {
return null;
}
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
Contact sales@docmost.com for support and enquiries.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
Enterprise {license.trial && <Badge color="green">Trial</Badge>}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Licensed to</Table.Th>
<Table.Td>{license.customerName}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Seat count</Table.Th>
<Table.Td>
{license.seatCount} ({workspace?.memberCount} used)
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Issued at</Table.Th>
<Table.Td>{format(license.issuedAt, "dd MMMM, yyyy")}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Expires at</Table.Th>
<Table.Td>{format(license.expiresAt, "dd MMMM, yyyy")}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>License ID</Table.Th>
<Table.Td>{license.id}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Td>
{isLicenseExpired(license) ? (
<Badge color="red" variant="light">
Expired
</Badge>
) : (
<Badge color="blue" variant="light">
Valid
</Badge>
)}
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -0,0 +1,3 @@
export default function LicenseMessage() {
return <>To unlock enterprise features, please contact sales@docmost.com to purchase a license.</>;
}

View File

@ -0,0 +1,39 @@
import { Group, Table, ThemeIcon } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
export default function OssDetails() {
return (
<Table.ScrollContainer minWidth={500} py="md">
<Table
variant="vertical"
verticalSpacing="sm"
layout="fixed"
withTableBorder
>
<Table.Caption>
To unlock enterprise features like SSO, contact sales@docmost.com.
</Table.Caption>
<Table.Tbody>
<Table.Tr>
<Table.Th w={160}>Edition</Table.Th>
<Table.Td>
<Group wrap="nowrap">
Open Source
<div>
<ThemeIcon
color="green"
variant="light"
size={24}
radius="xl"
>
<IconCheck size={16} />
</ThemeIcon>
</div>
</Group>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { useRemoveLicenseMutation } from "@/ee/licence/queries/license-query.ts";
import { Button, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import React from "react";
export default function RemoveLicense() {
const { t } = useTranslation();
const removeLicenseMutation = useRemoveLicenseMutation();
const openDeleteModal = () =>
modals.openConfirmModal({
title: t("Remove license key"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to remove your license key? Your workspace will be downgraded to the non-enterprise version.",
)}
</Text>
),
labels: { confirm: t("Remove"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: () => removeLicenseMutation.mutate(),
});
return (
<Group>
<Button variant="light" color="red" onClick={openDeleteModal}>Remove license</Button>
</Group>
);
}

View File

@ -0,0 +1,26 @@
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
import { differenceInDays, isAfter } from "date-fns";
export const GRACE_PERIOD_DAYS = 10;
export function isLicenseExpired(license: ILicenseInfo): boolean {
return isAfter(new Date(), license.expiresAt);
}
export function daysToExpire(license: ILicenseInfo): number {
const days = differenceInDays(license.expiresAt, new Date());
return days > 0 ? days : 0;
}
export function isTrial(license: ILicenseInfo): boolean {
return license.trial;
}
export function isValid(license: ILicenseInfo): boolean {
return !isLicenseExpired(license);
}
export function hasExpiredGracePeriod(license: ILicenseInfo): boolean {
if (!isLicenseExpired(license)) return false;
return differenceInDays(new Date(), license.expiresAt) > GRACE_PERIOD_DAYS;
}

View File

@ -0,0 +1,35 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import LicenseDetails from "@/ee/licence/components/license-details.tsx";
import ActivateLicenseForm from "@/ee/licence/components/activate-license-modal.tsx";
import InstallationDetails from "@/ee/licence/components/installation-details.tsx";
import OssDetails from "@/ee/licence/components/oss-details.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
export default function License() {
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>License - {getAppName()}</title>
</Helmet>
<SettingsTitle title="License" />
<ActivateLicenseForm />
<InstallationDetails />
{workspace?.hasLicenseKey ? <LicenseDetails /> : <OssDetails />}
</>
);
}

View File

@ -0,0 +1,52 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
activateLicense,
removeLicense,
getLicenseInfo,
} from "@/ee/licence/services/license-service.ts";
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
import { notifications } from "@mantine/notifications";
export function useLicenseInfo(): UseQueryResult<ILicenseInfo, Error> {
return useQuery({
queryKey: ["license"],
queryFn: () => getLicenseInfo(),
staleTime: 5 * 60 * 1000,
});
}
export function useActivateMutation() {
const queryClient = useQueryClient();
return useMutation<ILicenseInfo, Error, string>({
mutationFn: (licenseKey) => activateLicense(licenseKey),
onSuccess: () => {
notifications.show({ message: "License activated successfully" });
queryClient.refetchQueries({
queryKey: ["license"],
});
queryClient.refetchQueries({ queryKey: ["currentUser"] });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRemoveLicenseMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => removeLicense(),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["license"] });
queryClient.refetchQueries({ queryKey: ["currentUser"] });
},
});
}

View File

@ -0,0 +1,18 @@
import api from "@/lib/api-client.ts";
import { ILicenseInfo } from "@/ee/licence/types/license.types.ts";
export async function getLicenseInfo(): Promise<ILicenseInfo> {
const req = await api.post<ILicenseInfo>("/license/info");
return req.data;
}
export async function activateLicense(
licenseKey: string,
): Promise<ILicenseInfo> {
const req = await api.post<ILicenseInfo>("/license/activate", { licenseKey });
return req.data;
}
export async function removeLicense(): Promise<void> {
await api.post<void>("/license/remove");
}

View File

@ -0,0 +1,8 @@
export interface ILicenseInfo {
id: string;
customerName: string;
seatCount: number;
issuedAt: Date;
expiresAt: Date;
trial: boolean;
}

View File

@ -0,0 +1,20 @@
import { Helmet } from "react-helmet-async";
import { getAppName } from "@/lib/config.ts";
import { CloudLoginForm } from "@/ee/components/cloud-login-form.tsx";
import { useTranslation } from "react-i18next";
export default function CloudLogin() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("Login")} - {getAppName()}
</title>
</Helmet>
<CloudLoginForm />
</>
);
}

View File

@ -0,0 +1,15 @@
import { SetupWorkspaceForm } from "@/features/auth/components/setup-workspace-form.tsx";
import { Helmet } from "react-helmet-async";
import React from "react";
import { getAppName } from "@/lib/config.ts";
export default function CreateWorkspace() {
return (
<>
<Helmet>
<title>Create Workspace - {getAppName()}</title>
</Helmet>
<SetupWorkspaceForm />
</>
);
}

View File

@ -0,0 +1,88 @@
import { useAtom } from "jotai";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
const formSchema = z.object({
emailDomains: z.array(z.string()),
});
type FormValues = z.infer<typeof formSchema>;
export default function AllowedDomains() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [, setDomains] = useState<string[]>([]);
const form = useForm<FormValues>({
validate: zodResolver(formSchema),
initialValues: {
emailDomains: workspace?.emailDomains || [],
},
});
async function handleSubmit(data: Partial<IWorkspace>) {
setIsLoading(true);
try {
const updatedWorkspace = await updateWorkspace({
emailDomains: data.emailDomains,
});
setWorkspace(updatedWorkspace);
notifications.show({
message: t("Updated successfully"),
});
} catch (err) {
console.log(err);
notifications.show({
message: err.response.data.message,
color: "red",
});
}
form.resetDirty();
setIsLoading(false);
}
return (
<>
<div>
<Text size="md">Allowed email domains</Text>
<Text size="sm" c="dimmed">
Only users with email addresses from these domains can signup via SSO.
</Text>
</div>
<form onSubmit={form.onSubmit(handleSubmit)}>
<TagsInput
mt="sm"
description={t(
"Enter valid domain names separated by comma or space",
)}
placeholder={t("e.g acme.com")}
variant="filled"
splitChars={[",", " "]}
maxDropdownHeight={0}
maxTags={20}
onChange={setDomains}
{...form.getInputProps("emailDomains")}
/>
<Button
type="submit"
mt="sm"
disabled={!form.isDirty()}
loading={isLoading}
>
{t("Save")}
</Button>
</form>
</>
);
}

View File

@ -0,0 +1,79 @@
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 { 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";
import SsoProviderModal from "@/ee/security/components/sso-provider-modal.tsx";
import { OpenIdIcon } from "@/components/icons/openid-icon.tsx";
export default function CreateSsoProvider() {
const [opened, { open, close }] = useDisclosure(false);
const [provider, setProvider] = useState<IAuthProvider | null>(null);
const createSsoProviderMutation = useCreateSsoProviderMutation();
const handleCreateSAML = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.SAML,
name: "SAML",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create SAML provider", error);
}
};
const handleCreateOIDC = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.OIDC,
name: "OIDC",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create OIDC provider", error);
}
};
return (
<>
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
<Group justify="flex-end">
<Menu
transitionProps={{ transition: "pop-top-right" }}
position="bottom"
width={220}
withinPortal
>
<Menu.Target>
<Button rightSection={<IconChevronDown size={16} />} pr={12}>
Create SSO
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleCreateSAML}
leftSection={<IconLock size={16} />}
>
SAML
</Menu.Item>
<Menu.Item
onClick={handleCreateOIDC}
leftSection={<OpenIdIcon size={16} />}
>
OpenID (OIDC)
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</>
);
}

View File

@ -0,0 +1,61 @@
import { Group, Text, Switch, MantineSize } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
export default function EnforceSso() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enforce SSO")}</Text>
<Text size="sm" c="dimmed">
{t(
"Once enforced, members will not be able to login with email and password.",
)}
</Text>
</div>
<EnforceSsoToggle />
</Group>
);
}
interface EnforceSsoToggleProps {
size?: MantineSize;
label?: string;
}
export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.enforceSso);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ enforceSso: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Switch
size={size}
label={label}
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
aria-label={t("Toggle sso enforcement")}
/>
);
}

View File

@ -0,0 +1,91 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
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";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
const ssoSchema = z.object({
name: z.string().min(1, "Provider name is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoGoogleForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
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("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
placeholder="e.g Okta SSO"
readOnly
{...form.getInputProps("name")}
/>
<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

@ -0,0 +1,140 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
import { buildCallbackUrl } from "@/ee/security/sso.utils.ts";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
oidcIssuer: z.string().url(),
oidcClientId: z.string().min(1, "Client id is required"),
oidcClientSecret: z.string().min(1, "Client secret is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
oidcIssuer: provider.oidcIssuer || "",
oidcClientId: provider.oidcClientId || "",
oidcClientSecret: provider.oidcClientSecret || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
validate: zodResolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
providerId: provider.id,
type: provider.type,
});
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("oidcIssuer")) {
ssoData.oidcIssuer = values.oidcIssuer;
}
if (form.isDirty("oidcClientId")) {
ssoData.oidcClientId = values.oidcClientId;
}
if (form.isDirty("oidcClientSecret")) {
ssoData.oidcClientSecret = values.oidcClientSecret;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
placeholder="e.g Google SSO"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="Callback URL"
variant="filled"
value={callbackUrl}
pointer
readOnly
rightSection={<CopyTextButton text={callbackUrl} />}
/>
<TextInput
label="Issuer URL"
description="Enter your OIDC issuer URL"
placeholder="e.g https://accounts.google.com/"
{...form.getInputProps("oidcIssuer")}
/>
<TextInput
label="Client ID"
description="Enter your OIDC ClientId"
placeholder="e.g 292085223830.apps.googleusercontent.com"
{...form.getInputProps("oidcClientId")}
/>
<TextInput
label="Client Secret"
description="Enter your OIDC Client Secret"
placeholder="e.g OCSPX-zVCkotEPGRnJA1XKUrbgjlf7PQQ-"
{...form.getInputProps("oidcClientSecret")}
/>
<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

@ -0,0 +1,186 @@
import React, { useState } from "react";
import {
useDeleteSsoProviderMutation,
useGetSsoProviders,
} from "@/ee/security/queries/security-query.ts";
import {
ActionIcon,
Badge,
Card,
Group,
Menu,
Table,
Text,
ThemeIcon,
} from "@mantine/core";
import {
IconCheck,
IconDots,
IconLock,
IconPencil,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { useTranslation } from "react-i18next";
import SsoProviderModal from "@/ee/security/components/sso-provider-modal.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
export default function SsoProviderList() {
const { t } = useTranslation();
const { data, isLoading } = useGetSsoProviders();
const [opened, { open, close }] = useDisclosure(false);
const deleteSsoProviderMutation = useDeleteSsoProviderMutation();
const [editProvider, setEditProvider] = useState<IAuthProvider | null>(null);
if (isLoading || !data) {
return null;
}
if (data?.length === 0) {
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
}
const handleEdit = (provider: IAuthProvider) => {
setEditProvider(provider);
open();
};
const openDeleteModal = (providerId: string) =>
modals.openConfirmModal({
title: t("Delete SSO provider"),
centered: true,
children: (
<Text size="sm">
{t("Are you sure you want to delete this SSO provider?")}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Don't") },
confirmProps: { color: "red" },
onConfirm: () => deleteSsoProviderMutation.mutateAsync(providerId),
});
return (
<>
<Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Type")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th>{t("Allow signup")}</Table.Th>
<Table.Th>{t("Action")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data
.sort((a, b) => {
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
if (enabledDiff !== 0) return enabledDiff;
return a.name.localeCompare(b.name);
})
.map((provider: IAuthProvider, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)}
<div>
<Text fz="sm" fw={500}>
{provider.name}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge color={"gray"} variant="light">
{provider.type.toUpperCase()}
</Badge>
</Table.Td>
<Table.Td>
<Badge
color={provider.isEnabled ? "blue" : "gray"}
variant="light"
>
{provider.isEnabled ? "Active" : "InActive"}
</Badge>
</Table.Td>
<Table.Td>
{provider.allowSignup ? (
<ThemeIcon variant="light" size={24} radius="xl">
<IconCheck size={16} />
</ThemeIcon>
) : (
<ThemeIcon
variant="light"
color="red"
size={24}
radius="xl"
>
<IconX size={16} />
</ThemeIcon>
)}
</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
</ActionIcon>
<Menu
transitionProps={{ transition: "pop" }}
withArrow
position="bottom-end"
withinPortal
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => handleEdit(provider)}
leftSection={<IconPencil size={16} />}
>
{t("Edit")}
</Menu.Item>
<Menu.Item
onClick={() => openDeleteModal(provider.id)}
leftSection={<IconTrash size={16} />}
color="red"
disabled={provider.type === SSO_PROVIDER.GOOGLE}
>
{t("Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
<SsoProviderModal
opened={opened}
onClose={close}
provider={editProvider}
/>
</>
);
}

View File

@ -0,0 +1,43 @@
import React from "react";
import { Modal } from "@mantine/core";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
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";
interface SsoModalProps {
opened: boolean;
onClose: () => void;
provider: IAuthProvider | null;
}
export default function SsoProviderModal({
opened,
onClose,
provider,
}: SsoModalProps) {
if (!provider) {
return null;
}
return (
<Modal
opened={opened}
title={`${provider.type.toUpperCase()} Configuration`}
onClose={onClose}
>
{provider.type === SSO_PROVIDER.SAML && (
<SsoSamlForm provider={provider} onClose={onClose} />
)}
{provider.type === SSO_PROVIDER.OIDC && (
<SsoOIDCForm provider={provider} onClose={onClose} />
)}
{provider.type === SSO_PROVIDER.GOOGLE && (
<SsoGoogleForm provider={provider} onClose={onClose} />
)}
</Modal>
);
}

View File

@ -0,0 +1,153 @@
import React from "react";
import { z } from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Box,
Button,
Group,
Stack,
Switch,
Textarea,
TextInput,
} from "@mantine/core";
import {
buildCallbackUrl,
buildSamlEntityId,
} from "@/ee/security/sso.utils.ts";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import CopyTextButton from "@/components/common/copy.tsx";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
samlUrl: z.string().url(),
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
samlUrl: provider.samlUrl || "",
samlCertificate: provider.samlCertificate || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
},
validate: zodResolver(ssoSchema),
});
const callbackUrl = buildCallbackUrl({
providerId: provider.id,
type: provider.type,
});
const samlEntityId = buildSamlEntityId(provider.id);
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("samlUrl")) {
ssoData.samlUrl = values.samlUrl;
}
if (form.isDirty("samlCertificate")) {
ssoData.samlCertificate = values.samlCertificate;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
placeholder="e.g Azure Entra"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="Entity ID"
variant="filled"
value={buildSamlEntityId(provider.id)}
rightSection={<CopyTextButton text={samlEntityId} />}
pointer
readOnly
/>
<TextInput
label="Callback URL (ACS)"
variant="filled"
value={callbackUrl}
pointer
readOnly
rightSection={<CopyTextButton text={callbackUrl} />}
/>
<TextInput
label="IDP Login URL"
description="Enter your IDP login URL"
placeholder="e.g https://login.microsoftonline.com/7d6246d1-273b-4981-ad1e-e7bb27b86569/saml2"
{...form.getInputProps("samlUrl")}
/>
<Textarea
label="IDP Certificate"
description="Enter your IDP certificate"
placeholder="-----BEGIN CERTIFICATE-----"
autosize
minRows={3}
maxRows={5}
{...form.getInputProps("samlCertificate")}
/>
<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

@ -0,0 +1,14 @@
.item {
& + & {
padding-top: var(--mantine-spacing-sm);
margin-top: var(--mantine-spacing-sm);
border-top: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
}
}
.switch {
& * {
cursor: pointer;
}
}

View File

@ -0,0 +1,5 @@
export enum SSO_PROVIDER {
OIDC = 'oidc',
SAML = 'saml',
GOOGLE = 'google',
}

View File

@ -0,0 +1,52 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Divider, Title } from "@mantine/core";
import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
import EnforceSso from "@/ee/security/components/enforce-sso.tsx";
import AllowedDomains from "@/ee/security/components/allowed-domains.tsx";
import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import usePlan from "@/ee/hooks/use-plan.tsx";
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
if (!isAdmin) {
return null;
}
return (
<>
<Helmet>
<title>Security - {getAppName()}</title>
</Helmet>
<SettingsTitle title={t("Security")} />
<AllowedDomains />
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
</Title>
{(isCloud() && isBusiness) || (!isCloud() && hasLicenseKey) ? (
<>
<EnforceSso />
<Divider my="lg" />
<CreateSsoProvider />
<Divider size={0} my="lg" />
</>
) : null}
<SsoProviderList />
</>
);
}

View File

@ -0,0 +1,88 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createSsoProvider,
deleteSsoProvider,
getSsoProviderById,
getSsoProviders,
updateSsoProvider,
} from "@/ee/security/services/security-service.ts";
import { notifications } from "@mantine/notifications";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
export function useGetSsoProviders(): UseQueryResult<IAuthProvider[], Error> {
return useQuery({
queryKey: ["sso-providers"],
queryFn: () => getSsoProviders(),
staleTime: 5 * 60 * 1000,
});
}
export function useSsoProvider(
providerId: string,
): UseQueryResult<IAuthProvider, Error> {
return useQuery({
queryKey: ["sso-provider", providerId],
queryFn: () => getSsoProviderById({ providerId }),
enabled: !!providerId,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateSsoProviderMutation() {
const queryClient = useQueryClient();
return useMutation<any, Error, Partial<IAuthProvider>>({
mutationFn: (data: Partial<IAuthProvider>) => createSsoProvider(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["sso-providers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateSsoProviderMutation() {
const queryClient = useQueryClient();
return useMutation<any, Error, Partial<IAuthProvider>>({
mutationFn: (data: Partial<IAuthProvider>) => updateSsoProvider(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Updated successfully" });
queryClient.invalidateQueries({
queryKey: ["sso-providers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useDeleteSsoProviderMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (providerId: string) => deleteSsoProvider({ providerId }),
onSuccess: (data, variables) => {
notifications.show({ message: "Deleted successfully" });
queryClient.invalidateQueries({
queryKey: ["sso-providers"],
});
},
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.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
export async function getSsoProviderById(data: {
providerId: string;
}): Promise<any> {
const req = await api.post<IAuthProvider>("/sso/info");
return req.data;
}
export async function getSsoProviders(): Promise<IAuthProvider[]> {
const req = await api.post<IAuthProvider[]>("/sso/providers");
return req.data;
}
export async function createSsoProvider(data: any): Promise<IAuthProvider> {
const req = await api.post<IAuthProvider>("/sso/create", data);
return req.data;
}
export async function deleteSsoProvider(data: {
providerId: string;
}): Promise<void> {
await api.post<any>("/sso/delete", data);
}
export async function updateSsoProvider(
data: Partial<IAuthProvider>,
): Promise<IAuthProvider> {
const req = await api.post<IAuthProvider>("/sso/update", data);
return req.data;
}

View File

@ -0,0 +1,39 @@
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { getAppUrl, getServerAppUrl } from "@/lib/config.ts";
export function buildCallbackUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
}): string {
const { providerId, type } = opts;
const domain = getAppUrl();
if (type === SSO_PROVIDER.GOOGLE) {
return `${domain}/api/sso/${type}/callback`;
}
return `${domain}/api/sso/${type}/${providerId}/callback`;
}
export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
}): string {
const { providerId, type, workspaceId } = opts;
const domain = getAppUrl();
if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
}
return `${domain}/api/sso/${type}/${providerId}/login`;
}
export function getGoogleSignupUrl(): string {
// Google login is instance-wide. Use the env APP_URL instead
return `${getServerAppUrl()}/api/sso/${SSO_PROVIDER.GOOGLE}/signup`;
}
export function buildSamlEntityId(providerId: string): string {
const domain = getAppUrl();
return `${domain}/api/sso/${SSO_PROVIDER.SAML}/${providerId}/login`;
}

View File

@ -0,0 +1,20 @@
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
export interface IAuthProvider {
id: string;
name: string;
type: SSO_PROVIDER;
samlUrl: string;
samlCertificate: string;
oidcIssuer: string;
oidcClientId: string;
oidcClientSecret: string;
allowSignup: boolean;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
providerId: string;
}

View File

@ -0,0 +1,16 @@
import { getServerAppUrl, getSubdomainHost } from "@/lib/config.ts";
export function getHostnameUrl(hostname: string): string {
const url = new URL(getServerAppUrl());
const isHttps = url.protocol === "https:";
const protocol = isHttps ? "https" : "http";
return `${protocol}://${hostname}.${getSubdomainHost()}`;
}
export function exchangeTokenRedirectUrl(
hostname: string,
exchangeToken: string,
) {
return getHostnameUrl(hostname) + "/api/auth/exchange?token=" + exchangeToken;
}

View File

@ -2,4 +2,17 @@
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
border-radius: 4px;
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
margin-top: 150px;
margin-bottom: 20px;
@media (max-width: $mantine-breakpoint-sm) {
margin-top: 50px;
margin-bottom: 20px;
}
}
.containerBox {
margin-top: 40px;
}

View File

@ -35,8 +35,8 @@ export function ForgotPasswordForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Forgot password")}
</Title>

View File

@ -18,6 +18,7 @@ import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
const formSchema = z.object({
name: z.string().trim().min(1),
@ -65,45 +66,49 @@ export function InviteSignUpForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Join the workspace")}
</Title>
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<SsoLogin />
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
{!invitation.enforceSso && (
<Stack align="stretch" justify="center" gap="xl">
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="name"
type="text"
label={t("Name")}
placeholder={t("enter your full name")}
variant="filled"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
<TextInput
id="email"
type="email"
label={t("Email")}
value={invitation.email}
disabled
variant="filled"
mt="md"
/>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign Up")}
</Button>
</form>
</Stack>
)}
</Box>
</Container>
);

View File

@ -10,12 +10,17 @@ import {
PasswordInput,
Box,
Anchor,
Group,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { Link, useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
import SsoLogin from "@/ee/components/sso-login.tsx";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Error404 } from "@/components/ui/error-404.tsx";
import React from "react";
const formSchema = z.object({
email: z
@ -29,6 +34,12 @@ export function LoginForm() {
const { t } = useTranslation();
const { signIn, isLoading } = useAuth();
useRedirectIfAuthenticated();
const {
data,
isLoading: isDataLoading,
isError,
error,
} = useWorkspacePublicDataQuery();
const form = useForm<ILogin>({
validate: zodResolver(formSchema),
@ -42,44 +53,60 @@ export function LoginForm() {
await signIn(data);
}
if (isDataLoading) {
return null;
}
if (isError && error?.["response"]?.status === 404) {
return <Error404 />;
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Login")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<SsoLogin />
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
{!data?.enforceSso && (
<>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="email"
type="email"
label={t("Email")}
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Sign In")}
</Button>
</form>
<PasswordInput
label={t("Password")}
placeholder={t("Your password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
{t("Forgot your password?")}
</Anchor>
<Group justify="flex-end" mt="sm">
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
{t("Forgot your password?")}
</Anchor>
</Group>
<Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign In")}
</Button>
</form>
</>
)}
</Box>
</Container>
);

View File

@ -37,8 +37,8 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Password reset")}
</Title>

View File

@ -1,6 +1,5 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import {
Container,
@ -9,14 +8,20 @@ import {
Button,
PasswordInput,
Box,
Anchor,
Text,
} from "@mantine/core";
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
import useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css";
import { useTranslation } from "react-i18next";
import SsoCloudSignup from "@/ee/components/sso-cloud-signup.tsx";
import { isCloud } from "@/lib/config.ts";
import { Link } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().min(3).max(50),
workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1).max(50),
email: z
.string()
@ -45,55 +50,73 @@ export function SetupWorkspaceForm() {
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Create workspace")}
</Title>
<div>
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Create workspace")}
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
{isCloud() && <SsoCloudSignup />}
<TextInput
id="name"
type="text"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
{!isCloud() && (
<TextInput
id="workspaceName"
type="text"
label={t("Workspace Name")}
placeholder={t("e.g ACME Inc")}
variant="filled"
mt="md"
{...form.getInputProps("workspaceName")}
/>
)}
<TextInput
id="email"
type="email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
{...form.getInputProps("email")}
/>
<TextInput
id="name"
type="text"
label={t("Your Name")}
placeholder={t("enter your full name")}
variant="filled"
mt="md"
{...form.getInputProps("name")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Setup workspace")}
</Button>
</form>
</Box>
</Container>
<TextInput
id="email"
type="email"
label={t("Your Email")}
placeholder="email@example.com"
variant="filled"
mt="md"
{...form.getInputProps("email")}
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter a strong password")}
variant="filled"
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
{t("Create workspace")}
</Button>
</form>
</Box>
</Container>
{isCloud() && (
<Text ta="center">
{t("Already part of an existing workspace?")}{" "}
<Anchor
component={Link}
to={APP_ROUTE.AUTH.SELECT_WORKSPACE}
fw={500}
>
{t("Sign-in")}
</Anchor>
</Text>
)}
</div>
);
}

View File

@ -19,10 +19,15 @@ import {
} from "@/features/auth/types/auth.types";
import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
import {
acceptInvitation,
createWorkspace,
} from "@/features/workspace/services/workspace-service.ts";
import APP_ROUTE from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@ -67,9 +72,21 @@ export default function useAuth() {
setIsLoading(true);
try {
const res = await setupWorkspace(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
if (isCloud()) {
const res = await createWorkspace(data);
const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) {
window.location.href = exchangeTokenRedirectUrl(
hostname,
exchangeToken,
);
}
} else {
const res = await setupWorkspace(data);
setIsLoading(false);
navigate(APP_ROUTE.HOME);
}
} catch (err) {
setIsLoading(false);
notifications.show({

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