mirror of
https://github.com/docmost/docmost.git
synced 2025-11-11 09:12:05 +10:00
Compare commits
156 Commits
sentry
...
tiered-bil
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a16780d9f | |||
| 5fb90527ad | |||
| 65b01038d7 | |||
| e07cb57b01 | |||
| 2b53e0a455 | |||
| b9b3406b28 | |||
| 728cac0a34 | |||
| d35e16010b | |||
| 15791d4e59 | |||
| 3318e13225 | |||
| 080900610d | |||
| d1dc6977ab | |||
| 5f62448894 | |||
| 44445fbf46 | |||
| 1c674efddd | |||
| ccf7e34e99 | |||
| f39d48d6ee | |||
| f584ea84b0 | |||
| bc0c4d6258 | |||
| d8da307a61 | |||
| 50b3f9ddd9 | |||
| 0029f84d50 | |||
| 6d024fc3de | |||
| ce1503af85 | |||
| 69447fc375 | |||
| 858ff9da06 | |||
| 343b2976c2 | |||
| 7491224d0f | |||
| 4a0b4040ed | |||
| e3ba817723 | |||
| b0491d5da4 | |||
| 1c200dbd0f | |||
| fb7e4a7956 | |||
| 1413033568 | |||
| 00f4588c21 | |||
| 3a75251e75 | |||
| c6bca6a602 | |||
| 55d1a2c932 | |||
| bc3cb2d63f | |||
| 7adbf85030 | |||
| de7982fe30 | |||
| 0402f7efb5 | |||
| 8327251ab6 | |||
| e8847bd9cd | |||
| 9bbd62e0f0 | |||
| 0289c5cb09 | |||
| 7993532111 | |||
| 31e5c0c660 | |||
| 33c314d4e8 | |||
| 08f223899a | |||
| c528f7e858 | |||
| c26a851d52 | |||
| de5f90309c | |||
| 0ec3ff2965 | |||
| acffeacdbc | |||
| 00d92a3690 | |||
| 3430f715ec | |||
| 6c422011ac | |||
| 3e8824435d | |||
| 37a1804db9 | |||
| 882f3093bd | |||
| 1a1b2c8682 | |||
| 10b67929ea | |||
| 5c957fda8d | |||
| 862f6d4820 | |||
| de57d05199 | |||
| 89ec990232 | |||
| 49d0f1cc9a | |||
| 268001ae26 | |||
| 27fa45a769 | |||
| f9711918a3 | |||
| 29bb52db0c | |||
| f2241db5ee | |||
| 58d1855a36 | |||
| 7fe3c5f177 | |||
| 5fd477d074 | |||
| 4aa5d7e326 | |||
| 7f7f2bccd0 | |||
| a9f370660b | |||
| 117c7049ff | |||
| cd10365f71 | |||
| ee30d9d0f2 | |||
| 276ececbf2 | |||
| fa194a497c | |||
| 1eaba6e77f | |||
| 651e5f6153 | |||
| 7431804a46 | |||
| 3559358d14 | |||
| 06270ff747 | |||
| 233536314f | |||
| 17ce3bab8a | |||
| b27d1708b0 | |||
| 64f0531093 | |||
| 8aa604637e | |||
| 7ca2b437d4 | |||
| 595bd1dc81 | |||
| a74d3feae4 | |||
| e40faf97ec | |||
| bbe4fe99f9 | |||
| 8300c5b731 | |||
| 13039cfacc | |||
| 593f41a050 | |||
| f8ce160906 | |||
| c824b5b570 | |||
| 37e760d76c | |||
| 442fa23399 | |||
| 2e5990d057 | |||
| 15bdbf74cd | |||
| 3d9a7d808b | |||
| f45bdddb23 | |||
| 21c3ad0ecc | |||
| 573457403e | |||
| d021d0a38f | |||
| 96dfe9f817 | |||
| 598361992e | |||
| 210d1474ea | |||
| 5f520689ed | |||
| 2a535de29d | |||
| f45d9dc5a0 | |||
| f7a14e23cd | |||
| 1f40e9b960 | |||
| fea6518352 | |||
| 061a02ce51 | |||
| 2205ce0c3b | |||
| a812cdcf15 | |||
| 30acc6676a | |||
| 5c9e0a2630 | |||
| fd36076ae7 | |||
| dd52eb15ca | |||
| 6776e073b6 | |||
| 7a47da9273 | |||
| e62bc6c250 | |||
| 4f9e588494 | |||
| 05a3dfa26d | |||
| 8826cca539 | |||
| 1988feb9ce | |||
| e9b7273489 | |||
| 315afd6818 | |||
| 93ea31feb0 | |||
| 3b4e414c97 | |||
| d925c95fc9 | |||
| 4511db1526 | |||
| 56d9e46fd3 | |||
| cdea149ce7 | |||
| 16254802e3 | |||
| a7dd9b9198 | |||
| b81c9ee10c | |||
| 91596be70e | |||
| 72f64e7b10 | |||
| 3cfb17bb62 | |||
| fe5066c7b5 | |||
| e13be904cd | |||
| fda5c7d60f | |||
| 7fc1a782a7 | |||
| 54d27af76a | |||
| 0065f29634 |
@ -41,4 +41,6 @@ SMTP_IGNORETLS=false
|
|||||||
POSTMARK_TOKEN=
|
POSTMARK_TOKEN=
|
||||||
|
|
||||||
# for custom drawio server
|
# for custom drawio server
|
||||||
DRAWIO_URL=
|
DRAWIO_URL=
|
||||||
|
|
||||||
|
DISABLE_TELEMETRY=false
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
|
.env.dev
|
||||||
|
.env.prod
|
||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "apps/server/src/ee"]
|
||||||
|
path = apps/server/src/ee
|
||||||
|
url = https://github.com/docmost/ee
|
||||||
@ -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"
|
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@ -7,7 +7,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm@10.4.0
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ COPY --from=builder /app/pnpm*.yaml /app/
|
|||||||
# Copy patches
|
# Copy patches
|
||||||
COPY --from=builder /app/patches /app/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
|
RUN chown -R node:node /app
|
||||||
|
|
||||||
|
|||||||
42
README.md
42
README.md
@ -4,18 +4,18 @@
|
|||||||
Open-source collaborative wiki and documentation software.
|
Open-source collaborative wiki and documentation software.
|
||||||
<br />
|
<br />
|
||||||
<a href="https://docmost.com"><strong>Website</strong></a> |
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Docmost is currently in **beta**. We value your feedback as we progress towards a stable release.
|
|
||||||
|
|
||||||
## Getting started
|
## 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
|
## Features
|
||||||
|
|
||||||
- Real-time collaboration
|
- Real-time collaboration
|
||||||
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
||||||
- Spaces
|
- Spaces
|
||||||
@ -24,13 +24,39 @@ To get started with Docmost, please refer to our [documentation](https://docmost
|
|||||||
- Comments
|
- Comments
|
||||||
- Page history
|
- Page history
|
||||||
- Search
|
- Search
|
||||||
- File attachment
|
- File attachments
|
||||||
|
- Embeds (Airtable, Loom, Miro and more)
|
||||||
|
- Translations (10+ languages)
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
#### Screenshots
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
|
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
|
||||||
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
|
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
|
||||||
</p>
|
</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)
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Docmost</title>
|
<title>Docmost</title>
|
||||||
|
<!--meta-tags-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.3",
|
"version": "0.21.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
@ -15,40 +15,46 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "^0.17.6",
|
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||||
"@mantine/core": "^7.14.2",
|
"@mantine/core": "^7.17.0",
|
||||||
"@mantine/form": "^7.14.2",
|
"@mantine/form": "^7.17.0",
|
||||||
"@mantine/hooks": "^7.14.2",
|
"@mantine/hooks": "^7.17.0",
|
||||||
"@mantine/modals": "^7.14.2",
|
"@mantine/modals": "^7.17.0",
|
||||||
"@mantine/notifications": "^7.14.2",
|
"@mantine/notifications": "^7.17.0",
|
||||||
"@mantine/spotlight": "^7.14.2",
|
"@mantine/spotlight": "^7.17.0",
|
||||||
"@tabler/icons-react": "^3.22.0",
|
"@tabler/icons-react": "^3.34.0",
|
||||||
"@tanstack/react-query": "^5.61.4",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
"axios": "^1.7.9",
|
"@tiptap/extension-character-count": "^2.10.3",
|
||||||
|
"alfaaz": "^1.1.0",
|
||||||
|
"axios": "^1.9.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"highlightjs-sap-abap": "^0.3.0",
|
||||||
"i18next": "^23.14.0",
|
"i18next": "^23.14.0",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"jotai": "^2.10.3",
|
"jotai": "^2.12.5",
|
||||||
"jotai-optics": "^0.4.0",
|
"jotai-optics": "^0.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"katex": "0.16.21",
|
"jwt-decode": "^4.0.0",
|
||||||
"lowlight": "^3.2.0",
|
"katex": "0.16.22",
|
||||||
"mermaid": "^11.4.1",
|
"lowlight": "^3.3.0",
|
||||||
|
"mermaid": "^11.6.0",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-arborist": "3.4.0",
|
"react-arborist": "3.4.0",
|
||||||
"react-clear-modal": "^2.0.11",
|
"react-clear-modal": "^2.0.15",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "^1.0.1",
|
"react-drawio": "^1.0.1",
|
||||||
"react-error-boundary": "^4.1.2",
|
"react-error-boundary": "^4.1.2",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-i18next": "^15.0.1",
|
"react-i18next": "^15.0.1",
|
||||||
"react-router-dom": "^7.0.1",
|
"react-router-dom": "^7.0.1",
|
||||||
|
"semver": "^7.7.2",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.16",
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.25.56"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
@ -59,7 +65,7 @@
|
|||||||
"@types/node": "22.10.0",
|
"@types/node": "22.10.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
@ -72,6 +78,6 @@
|
|||||||
"prettier": "^3.4.1",
|
"prettier": "^3.4.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.17.0",
|
"typescript-eslint": "^8.17.0",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,6 +148,7 @@
|
|||||||
"Select role to assign to all invited members": "Rolle für alle eingeladenen Mitglieder auswählen",
|
"Select role to assign to all invited members": "Rolle für alle eingeladenen Mitglieder auswählen",
|
||||||
"Select theme": "Design auswählen",
|
"Select theme": "Design auswählen",
|
||||||
"Send invitation": "Einladung senden",
|
"Send invitation": "Einladung senden",
|
||||||
|
"Invitation sent": "Einladung gesendet",
|
||||||
"Settings": "Einstellungen",
|
"Settings": "Einstellungen",
|
||||||
"Setup workspace": "Arbeitsbereich einrichten",
|
"Setup workspace": "Arbeitsbereich einrichten",
|
||||||
"Sign In": "Anmelden",
|
"Sign In": "Anmelden",
|
||||||
@ -244,6 +245,7 @@
|
|||||||
"Align left": "Links ausrichten",
|
"Align left": "Links ausrichten",
|
||||||
"Align right": "Rechts ausrichten",
|
"Align right": "Rechts ausrichten",
|
||||||
"Align center": "Zentrieren",
|
"Align center": "Zentrieren",
|
||||||
|
"Justify": "Blocksatz",
|
||||||
"Merge cells": "Zellen zusammenführen",
|
"Merge cells": "Zellen zusammenführen",
|
||||||
"Split cell": "Zelle teilen",
|
"Split cell": "Zelle teilen",
|
||||||
"Delete column": "Spalte löschen",
|
"Delete column": "Spalte löschen",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
||||||
"Names do not match": "Namen stimmen nicht überein",
|
"Names do not match": "Namen stimmen nicht überein",
|
||||||
"Today, {{time}}": "Heute, {{time}}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -148,6 +148,7 @@
|
|||||||
"Select role to assign to all invited members": "Select role to assign to all invited members",
|
"Select role to assign to all invited members": "Select role to assign to all invited members",
|
||||||
"Select theme": "Select theme",
|
"Select theme": "Select theme",
|
||||||
"Send invitation": "Send invitation",
|
"Send invitation": "Send invitation",
|
||||||
|
"Invitation sent": "Invitation sent",
|
||||||
"Settings": "Settings",
|
"Settings": "Settings",
|
||||||
"Setup workspace": "Setup workspace",
|
"Setup workspace": "Setup workspace",
|
||||||
"Sign In": "Sign In",
|
"Sign In": "Sign In",
|
||||||
@ -339,5 +340,54 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||||
"Names do not match": "Names do not match",
|
"Names do not match": "Names do not match",
|
||||||
"Today, {{time}}": "Today, {{time}}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
"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",
|
"Join the workspace": "Unirse al espacio de trabajo",
|
||||||
"Language": "Idioma",
|
"Language": "Idioma",
|
||||||
"Light": "Ligero",
|
"Light": "Claro",
|
||||||
"Link copied": "Enlace copiado",
|
"Link copied": "Enlace copiado",
|
||||||
"Login": "Iniciar sesión",
|
"Login": "Iniciar sesión",
|
||||||
"Logout": "Cerrar 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 role to assign to all invited members": "Seleccionar rol para asignar a todos los miembros invitados",
|
||||||
"Select theme": "Seleccionar tema",
|
"Select theme": "Seleccionar tema",
|
||||||
"Send invitation": "Enviar invitación",
|
"Send invitation": "Enviar invitación",
|
||||||
|
"Invitation sent": "Invitación enviada",
|
||||||
"Settings": "Ajustes",
|
"Settings": "Ajustes",
|
||||||
"Setup workspace": "Configurar espacio de trabajo",
|
"Setup workspace": "Configurar espacio de trabajo",
|
||||||
"Sign In": "Iniciar sesión",
|
"Sign In": "Iniciar sesión",
|
||||||
@ -244,6 +245,7 @@
|
|||||||
"Align left": "Alinear a la izquierda",
|
"Align left": "Alinear a la izquierda",
|
||||||
"Align right": "Alinear a la derecha",
|
"Align right": "Alinear a la derecha",
|
||||||
"Align center": "Alinear al centro",
|
"Align center": "Alinear al centro",
|
||||||
|
"Justify": "Justificar",
|
||||||
"Merge cells": "Combinar celdas",
|
"Merge cells": "Combinar celdas",
|
||||||
"Split cell": "Dividir celda",
|
"Split cell": "Dividir celda",
|
||||||
"Delete column": "Eliminar columna",
|
"Delete column": "Eliminar columna",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
||||||
"Names do not match": "Los nombres no coinciden",
|
"Names do not match": "Los nombres no coinciden",
|
||||||
"Today, {{time}}": "Hoy, {{time}}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
"Can view": "Peut voir",
|
"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.",
|
"Can view pages in space but not edit.": "Peut voir les pages dans l'espace mais ne peut pas les modifier.",
|
||||||
"Cancel": "Annuler",
|
"Cancel": "Annuler",
|
||||||
"Change email": "Changer l'email",
|
"Change email": "Changer le courriel",
|
||||||
"Change password": "Changer le mot de passe",
|
"Change password": "Changer le mot de passe",
|
||||||
"Change photo": "Changer la photo",
|
"Change photo": "Changer la photo",
|
||||||
"Choose a role": "Choisir un rôle",
|
"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 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",
|
"Select theme": "Sélectionner le thème",
|
||||||
"Send invitation": "Envoyer l'invitation",
|
"Send invitation": "Envoyer l'invitation",
|
||||||
|
"Invitation sent": "Invitation envoyée",
|
||||||
"Settings": "Paramètres",
|
"Settings": "Paramètres",
|
||||||
"Setup workspace": "Configurer l'espace de travail",
|
"Setup workspace": "Configurer l'espace de travail",
|
||||||
"Sign In": "Se connecter",
|
"Sign In": "Se connecter",
|
||||||
@ -244,6 +245,7 @@
|
|||||||
"Align left": "Aligner à gauche",
|
"Align left": "Aligner à gauche",
|
||||||
"Align right": "Aligner à droite",
|
"Align right": "Aligner à droite",
|
||||||
"Align center": "Aligner au centre",
|
"Align center": "Aligner au centre",
|
||||||
|
"Justify": "Justifier",
|
||||||
"Merge cells": "Fusionner les cellules",
|
"Merge cells": "Fusionner les cellules",
|
||||||
"Split cell": "Diviser la cellule",
|
"Split cell": "Diviser la cellule",
|
||||||
"Delete column": "Supprimer la colonne",
|
"Delete column": "Supprimer la colonne",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
||||||
"Names do not match": "Les noms ne correspondent pas",
|
"Names do not match": "Les noms ne correspondent pas",
|
||||||
"Today, {{time}}": "Aujourd'hui, {{time}}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 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 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 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 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 create and edit pages in space.": "Può creare e modificare le pagine nello spazio.",
|
||||||
"Can edit": "Può modificare",
|
"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 manage workspace but cannot delete it": "Può gestire lo spazio di lavoro ma non può eliminarlo",
|
||||||
"Can view": "Può visualizzare",
|
"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",
|
"Cancel": "Annulla",
|
||||||
"Change email": "Cambia email",
|
"Change email": "Cambia email",
|
||||||
"Change password": "Cambia password",
|
"Change password": "Cambia password",
|
||||||
"Change photo": "Cambia foto",
|
"Change photo": "Cambia foto",
|
||||||
"Choose a role": "Scegli un ruolo",
|
"Choose a role": "Scegli un ruolo",
|
||||||
"Choose your preferred color scheme.": "Scegli il tuo schema di colori preferito.",
|
"Choose your preferred color scheme.": "Scegli il tema che preferisci.",
|
||||||
"Choose your preferred interface language.": "Scegli la tua lingua preferita per l'interfaccia.",
|
"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.",
|
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||||
"Confirm": "Conferma",
|
"Confirm": "Conferma",
|
||||||
"Copy link": "Copia link",
|
"Copy link": "Copia link",
|
||||||
@ -34,7 +34,7 @@
|
|||||||
"Create group": "Crea gruppo",
|
"Create group": "Crea gruppo",
|
||||||
"Create page": "Crea pagina",
|
"Create page": "Crea pagina",
|
||||||
"Create space": "Crea spazio",
|
"Create space": "Crea spazio",
|
||||||
"Create workspace": "Crea spazio di lavoro",
|
"Create workspace": "Crea area di lavoro",
|
||||||
"Current password": "Password attuale",
|
"Current password": "Password attuale",
|
||||||
"Dark": "Scuro",
|
"Dark": "Scuro",
|
||||||
"Date": "Data",
|
"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.",
|
"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",
|
"Description": "Descrizione",
|
||||||
"Details": "Dettagli",
|
"Details": "Dettagli",
|
||||||
"e.g ACME": "ad es. ACME",
|
"e.g ACME": "es. ACME",
|
||||||
"e.g ACME Inc": "es. ACME Inc",
|
"e.g ACME Inc": "es. ACME Inc",
|
||||||
"e.g Developers": "es. Sviluppatori",
|
"e.g Developers": "es. Sviluppatori",
|
||||||
"e.g Group for developers": "es. Gruppo per gli 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 Product Team": "es. Team di Prodotto",
|
||||||
"e.g Sales": "ad es. Vendite",
|
"e.g Sales": "es. Vendite",
|
||||||
"e.g Space for product team": "ad es. Spazio per il team di prodotto",
|
"e.g Space for product team": "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 Space for sales team to collaborate": "es. Spazio per la collaborazione del team di vendita",
|
||||||
"Edit": "Modifica",
|
"Edit": "Modifica",
|
||||||
"Edit group": "Modifica gruppo",
|
"Edit group": "Modifica gruppo",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Enter a strong password": "Inserisci una password sicura",
|
"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 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 indirizzi email validi",
|
"enter valid emails addresses": "inserisci degli indirizzi email validi",
|
||||||
"Enter your current password": "Inserisci la tua password attuale",
|
"Enter your current password": "Inserisci la tua password attuale",
|
||||||
"enter your full name": "inserisci il tuo nome completo",
|
"enter your full name": "inserisci il tuo nome completo",
|
||||||
"Enter your new password": "Inserisci la tua nuova password",
|
"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 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.",
|
"Error loading page history.": "Si è verificato un errore durante il caricamento della cronologia della pagina.",
|
||||||
"Export": "Esporta",
|
"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 delete page": "Impossibile eliminare la pagina",
|
||||||
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
|
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
|
||||||
"Failed to import pages": "Impossibile importare le pagine",
|
"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 load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
|
||||||
"Failed to update data": "Impossibile aggiornare i dati",
|
"Failed to update data": "Impossibile aggiornare i dati",
|
||||||
"Full access": "Accesso completo",
|
"Full access": "Accesso completo",
|
||||||
"Full page width": "Larghezza pagina intera",
|
"Full page width": "Pagina a larghezza intera",
|
||||||
"Full width": "Larghezza intera",
|
"Full width": "Larghezza intera",
|
||||||
"General": "Generale",
|
"General": "Generale",
|
||||||
"Group": "Gruppo",
|
"Group": "Gruppo",
|
||||||
"Group description": "Descrizione del gruppo",
|
"Group description": "Descrizione del gruppo",
|
||||||
"Group name": "Nome del gruppo",
|
"Group name": "Nome del gruppo",
|
||||||
"Groups": "Gruppi",
|
"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",
|
"Home": "Casa",
|
||||||
"Import pages": "Importa pagine",
|
"Import pages": "Importa pagine",
|
||||||
"Import pages & space settings": "Importa pagine e impostazioni dello spazio",
|
"Import pages & space settings": "Importa pagine e impostazioni dello spazio",
|
||||||
"Importing pages": "Importazione pagine",
|
"Importing pages": "Importazione pagine",
|
||||||
"invalid invitation link": "link di invito non valido",
|
"invalid invitation link": "link di invito non valido",
|
||||||
"Invitation signup": "Iscrizione invito",
|
"Invitation signup": "Iscrizione invito",
|
||||||
"Invite by email": "Invita via email",
|
"Invite by email": "Invita tramite email",
|
||||||
"Invite members": "Invita membri",
|
"Invite members": "Invita membri",
|
||||||
"Invite new members": "Invita nuovi 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 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",
|
"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",
|
"Language": "Lingua",
|
||||||
"Light": "Chiaro",
|
"Light": "Chiaro",
|
||||||
"Link copied": "Link copiato",
|
"Link copied": "Link copiato",
|
||||||
@ -105,15 +105,15 @@
|
|||||||
"members": "membri",
|
"members": "membri",
|
||||||
"Members": "Membri",
|
"Members": "Membri",
|
||||||
"My preferences": "Le mie preferenze",
|
"My preferences": "Le mie preferenze",
|
||||||
"My Profile": "Il mio profilo",
|
"My Profile": "Il Mio Profilo",
|
||||||
"My profile": "Il mio profilo",
|
"My profile": "Il mio profilo",
|
||||||
"Name": "Nome",
|
"Name": "Nome",
|
||||||
"New email": "Nuova email",
|
"New email": "Nuova email",
|
||||||
"New page": "Nuova pagina",
|
"New page": "Nuova pagina",
|
||||||
"New password": "Nuova password",
|
"New password": "Nuova password",
|
||||||
"No group found": "Nessun gruppo trovato",
|
"No group found": "Nessun gruppo trovato",
|
||||||
"No page history saved yet.": "Nessuna cronologia della pagina salvata.",
|
"No page history saved yet.": "La pagina non ha una cronologia per ora.",
|
||||||
"No pages yet": "Nessuna pagina ancora",
|
"No pages yet": "Nessuna pagina per ora",
|
||||||
"No results found...": "Nessun risultato trovato...",
|
"No results found...": "Nessun risultato trovato...",
|
||||||
"No user found": "Nessun utente trovato",
|
"No user found": "Nessun utente trovato",
|
||||||
"Overview": "Panoramica",
|
"Overview": "Panoramica",
|
||||||
@ -139,49 +139,50 @@
|
|||||||
"Role": "Ruolo",
|
"Role": "Ruolo",
|
||||||
"Save": "Salva",
|
"Save": "Salva",
|
||||||
"Search": "Cerca",
|
"Search": "Cerca",
|
||||||
"Search for groups": "Cerca gruppi",
|
"Search for groups": "Cerca un gruppo",
|
||||||
"Search for users": "Cerca un utente",
|
"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...",
|
"Search...": "Cerca...",
|
||||||
"Select language": "Seleziona lingua",
|
"Select language": "Seleziona una lingua",
|
||||||
"Select role": "Seleziona ruolo",
|
"Select role": "Seleziona un ruolo",
|
||||||
"Select role to assign to all invited members": "Seleziona il ruolo da assegnare a tutti i membri invitati",
|
"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",
|
"Send invitation": "Invia invito",
|
||||||
|
"Invitation sent": "Invito inviato",
|
||||||
"Settings": "Impostazioni",
|
"Settings": "Impostazioni",
|
||||||
"Setup workspace": "Imposta spazio di lavoro",
|
"Setup workspace": "Configura l'area di lavoro",
|
||||||
"Sign In": "Accedi",
|
"Sign In": "Accedi",
|
||||||
"Sign Up": "Registrati",
|
"Sign Up": "Registrati",
|
||||||
"Slug": "Identificatore",
|
"Slug": "Slug",
|
||||||
"Space": "Spazio",
|
"Space": "Spazio",
|
||||||
"Space description": "Descrizione dello spazio",
|
"Space description": "Descrizione dello spazio",
|
||||||
"Space menu": "Menu spazio",
|
"Space menu": "Menu spazio",
|
||||||
"Space name": "Nome dello spazio",
|
"Space name": "Nome dello spazio",
|
||||||
"Space settings": "Impostazioni dello spazio",
|
"Space settings": "Impostazioni dello spazio",
|
||||||
"Space slug": "Lumaca spaziale",
|
"Space slug": "Slug dello spazio",
|
||||||
"Spaces": "Spazi",
|
"Spaces": "Spazi",
|
||||||
"Spaces you belong to": "Spazi a cui appartieni",
|
"Spaces you belong to": "Spazi a cui appartieni",
|
||||||
"No space found": "Nessuno spazio trovato",
|
"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...",
|
"Start typing to search...": "Inizia a digitare per cercare...",
|
||||||
"Status": "Stato",
|
"Status": "Stato",
|
||||||
"Successfully imported": "Importazione riuscita",
|
"Successfully imported": "Importato con successo",
|
||||||
"Successfully restored": "Ripristinato con successo",
|
"Successfully restored": "Ripristinato con successo",
|
||||||
"System settings": "Impostazioni di sistema",
|
"System settings": "Impostazioni di sistema",
|
||||||
"Theme": "Tema",
|
"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.",
|
"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.",
|
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
|
||||||
"untitled": "senza titolo",
|
"untitled": "senza titolo",
|
||||||
"Untitled": "Senza titolo",
|
"Untitled": "Senza titolo",
|
||||||
"Updated successfully": "Aggiornato con successo",
|
"Updated successfully": "Aggiornato con successo",
|
||||||
"User": "Utente",
|
"User": "Utente",
|
||||||
"Workspace": "Spazio di lavoro",
|
"Workspace": "Area di lavoro",
|
||||||
"Workspace Name": "Nome dello spazio di lavoro",
|
"Workspace Name": "Nome dell'area di lavoro",
|
||||||
"Workspace settings": "Impostazioni dello spazio di lavoro",
|
"Workspace settings": "Impostazioni dell'area di lavoro",
|
||||||
"You can change your password here.": "Puoi cambiare la tua password qui.",
|
"You can change your password here.": "Qui puoi cambiare la tua password.",
|
||||||
"Your Email": "La tua email",
|
"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 Name": "Il Tuo Nome",
|
"Your Name": "Il Tuo Nome",
|
||||||
"Your password": "La tua password",
|
"Your password": "La tua password",
|
||||||
@ -190,18 +191,18 @@
|
|||||||
"Comments": "Commenti",
|
"Comments": "Commenti",
|
||||||
"404 page not found": "404 pagina non trovata",
|
"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.",
|
"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",
|
"Take me back to homepage": "Torna all'homepage",
|
||||||
"Forgot password": "Hai dimenticato la password",
|
"Forgot password": "Password dimenticata",
|
||||||
"Forgot your password?": "Hai dimenticato la password?",
|
"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.",
|
"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",
|
"Password reset": "Reimposta password",
|
||||||
"Your new password": "La tua nuova password",
|
"Your new password": "La tua nuova password",
|
||||||
"Set password": "Imposta password",
|
"Set password": "Imposta password",
|
||||||
"Write a comment": "Scrivi un commento",
|
"Write a comment": "Scrivi un commento",
|
||||||
"Reply...": "Rispondi...",
|
"Reply...": "Rispondi...",
|
||||||
"Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.",
|
"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",
|
"Edit comment": "Modifica commento",
|
||||||
"Delete comment": "Elimina commento",
|
"Delete comment": "Elimina commento",
|
||||||
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo 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 invitation": "Revoca invito",
|
||||||
"Revoke": "Revoca",
|
"Revoke": "Revoca",
|
||||||
"Don't": "Non",
|
"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",
|
"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",
|
"Invite link": "Link d'invito",
|
||||||
"Copy": "Copia",
|
"Copy": "Copia",
|
||||||
"Copied": "Copiato",
|
"Copied": "Copiato",
|
||||||
"Select a user": "Seleziona un utente",
|
"Select a user": "Seleziona un utente",
|
||||||
"Select a group": "Seleziona un gruppo",
|
"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",
|
"Delete space": "Elimina spazio",
|
||||||
"Are you sure you want to delete this space?": "Sei sicuro di voler eliminare questo 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.",
|
"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",
|
"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.",
|
"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",
|
"Format": "Formato",
|
||||||
@ -240,10 +241,11 @@
|
|||||||
"Export page": "Esporta pagina",
|
"Export page": "Esporta pagina",
|
||||||
"Export space": "Esporta spazio",
|
"Export space": "Esporta spazio",
|
||||||
"Export {{type}}": "Esporta {{type}}",
|
"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 left": "Allinea a sinistra",
|
||||||
"Align right": "Allinea a destra",
|
"Align right": "Allinea a destra",
|
||||||
"Align center": "Allinea al centro",
|
"Align center": "Allinea al centro",
|
||||||
|
"Justify": "Giustifica",
|
||||||
"Merge cells": "Unisci celle",
|
"Merge cells": "Unisci celle",
|
||||||
"Split cell": "Dividi cella",
|
"Split cell": "Dividi cella",
|
||||||
"Delete column": "Elimina colonna",
|
"Delete column": "Elimina colonna",
|
||||||
@ -259,10 +261,10 @@
|
|||||||
"Danger": "Pericolo",
|
"Danger": "Pericolo",
|
||||||
"Mermaid diagram error:": "Errore nel diagramma di Mermaid:",
|
"Mermaid diagram error:": "Errore nel diagramma di Mermaid:",
|
||||||
"Invalid Mermaid diagram": "Diagramma di Mermaid non valido",
|
"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",
|
"Exit": "Esci",
|
||||||
"Save & Exit": "Salva ed 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",
|
"Paste link": "Incolla link",
|
||||||
"Edit link": "Modifica link",
|
"Edit link": "Modifica link",
|
||||||
"Remove link": "Rimuovi link",
|
"Remove link": "Rimuovi link",
|
||||||
@ -298,7 +300,7 @@
|
|||||||
"To-do List": "Lista delle cose da fare",
|
"To-do List": "Lista delle cose da fare",
|
||||||
"Bullet List": "Elenco Puntato",
|
"Bullet List": "Elenco Puntato",
|
||||||
"Numbered List": "Elenco Numerato",
|
"Numbered List": "Elenco Numerato",
|
||||||
"Blockquote": "Blocco di citazione",
|
"Blockquote": "Citazione",
|
||||||
"Just start typing with plain text.": "Inizia a digitare con testo semplice.",
|
"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.",
|
"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.",
|
"Big section heading.": "Intestazione di una grande sezione.",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
||||||
"Names do not match": "I nomi non corrispondono",
|
"Names do not match": "I nomi non corrispondono",
|
||||||
"Today, {{time}}": "Oggi, {{time}}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": "招待が送信されました",
|
||||||
"Settings": "設定",
|
"Settings": "設定",
|
||||||
"Setup workspace": "ワークスペースを設定する",
|
"Setup workspace": "ワークスペースを設定する",
|
||||||
"Sign In": "サインイン",
|
"Sign In": "サインイン",
|
||||||
@ -244,6 +245,7 @@
|
|||||||
"Align left": "左揃え",
|
"Align left": "左揃え",
|
||||||
"Align right": "右揃え",
|
"Align right": "右揃え",
|
||||||
"Align center": "中央揃え",
|
"Align center": "中央揃え",
|
||||||
|
"Justify": "両端揃え",
|
||||||
"Merge cells": "セルを結合",
|
"Merge cells": "セルを結合",
|
||||||
"Split cell": "セルを分割",
|
"Split cell": "セルを分割",
|
||||||
"Delete column": "列を削除",
|
"Delete column": "列を削除",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
||||||
"Names do not match": "名前が一致しません",
|
"Names do not match": "名前が一致しません",
|
||||||
"Today, {{time}}": "今日、{{time}}",
|
"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": "ページのコピーに成功しました"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
"Enter a strong password": "강력한 비밀번호를 입력하세요",
|
||||||
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
"Enter valid email addresses separated by comma or space max_50": "유효한 이메일 주소를 쉼표나 공백으로 구분하여 입력하세요 [최대: 50]",
|
||||||
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
"enter valid emails addresses": "유효한 이메일 주소를 입력하세요",
|
||||||
"Enter your current password": "현재 비밀번호를 입력하세요",
|
"Enter your current password": "기존 비밀번호를 입력하세요",
|
||||||
"enter your full name": "전체 이름을 입력하세요",
|
"enter your full name": "전체 이름을 입력하세요",
|
||||||
"Enter your new password": "새 비밀번호를 입력하세요",
|
"Enter your new password": "새 비밀번호를 입력하세요",
|
||||||
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
"Enter your new preferred email": "새로운 이메일을 입력하세요",
|
||||||
@ -84,7 +84,7 @@
|
|||||||
"Home": "홈",
|
"Home": "홈",
|
||||||
"Import pages": "페이지 가져오기",
|
"Import pages": "페이지 가져오기",
|
||||||
"Import pages & space settings": "페이지 및 Space 설정 가져오기",
|
"Import pages & space settings": "페이지 및 Space 설정 가져오기",
|
||||||
"Importing pages": "페이지 가져오기 중",
|
"Importing pages": "페이지 가져오는 중",
|
||||||
"invalid invitation link": "유효하지 않은 초대 링크",
|
"invalid invitation link": "유효하지 않은 초대 링크",
|
||||||
"Invitation signup": "초대 가입",
|
"Invitation signup": "초대 가입",
|
||||||
"Invite by email": "이메일로 초대",
|
"Invite by email": "이메일로 초대",
|
||||||
@ -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": "초대 발송 완료",
|
||||||
"Settings": "설정",
|
"Settings": "설정",
|
||||||
"Setup workspace": "Workspace 설정",
|
"Setup workspace": "Workspace 설정",
|
||||||
"Sign In": "로그인",
|
"Sign In": "로그인",
|
||||||
@ -169,7 +170,7 @@
|
|||||||
"Successfully restored": "복원 완료",
|
"Successfully restored": "복원 완료",
|
||||||
"System settings": "시스템 설정",
|
"System settings": "시스템 설정",
|
||||||
"Theme": "배경",
|
"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": "전체 페이지 너비 전환",
|
"Toggle full page width": "전체 페이지 너비 전환",
|
||||||
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
"Unable to import pages. Please try again.": "페이지를 가져올 수 없습니다. 다시 시도해주세요.",
|
||||||
"untitled": "제목 없음",
|
"untitled": "제목 없음",
|
||||||
@ -218,7 +219,7 @@
|
|||||||
"Don't": "하지 않음",
|
"Don't": "하지 않음",
|
||||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
|
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
|
||||||
"Resend invitation": "초대 재전송",
|
"Resend invitation": "초대 재전송",
|
||||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사람이 Workspace에 참여할 수 있습니다.",
|
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
|
||||||
"Invite link": "초대 링크",
|
"Invite link": "초대 링크",
|
||||||
"Copy": "복사",
|
"Copy": "복사",
|
||||||
"Copied": "복사됨",
|
"Copied": "복사됨",
|
||||||
@ -244,6 +245,7 @@
|
|||||||
"Align left": "왼쪽 정렬",
|
"Align left": "왼쪽 정렬",
|
||||||
"Align right": "오른쪽 정렬",
|
"Align right": "오른쪽 정렬",
|
||||||
"Align center": "가운데 정렬",
|
"Align center": "가운데 정렬",
|
||||||
|
"Justify": "정렬",
|
||||||
"Merge cells": "셀 병합",
|
"Merge cells": "셀 병합",
|
||||||
"Split cell": "셀 분할",
|
"Split cell": "셀 분할",
|
||||||
"Delete column": "열 삭제",
|
"Delete column": "열 삭제",
|
||||||
@ -255,7 +257,7 @@
|
|||||||
"Delete table": "테이블 삭제",
|
"Delete table": "테이블 삭제",
|
||||||
"Info": "정보",
|
"Info": "정보",
|
||||||
"Success": "완료",
|
"Success": "완료",
|
||||||
"Warning": "경고",
|
"Warning": "주의",
|
||||||
"Danger": "위험",
|
"Danger": "위험",
|
||||||
"Mermaid diagram error:": "Mermaid diagram 오류:",
|
"Mermaid diagram error:": "Mermaid diagram 오류:",
|
||||||
"Invalid Mermaid diagram": "잘못된 Mermaid diagram",
|
"Invalid Mermaid diagram": "잘못된 Mermaid diagram",
|
||||||
@ -264,7 +266,7 @@
|
|||||||
"Save & Exit": "저장 후 나가기",
|
"Save & Exit": "저장 후 나가기",
|
||||||
"Double-click to edit Excalidraw diagram": "Excalidraw diagram을 편집하려면 더블 클릭하세요",
|
"Double-click to edit Excalidraw diagram": "Excalidraw diagram을 편집하려면 더블 클릭하세요",
|
||||||
"Paste link": "링크 붙여넣기",
|
"Paste link": "링크 붙여넣기",
|
||||||
"Edit link": "링크 편집",
|
"Edit link": "링크 수정",
|
||||||
"Remove link": "링크 제거",
|
"Remove link": "링크 제거",
|
||||||
"Add link": "링크 추가",
|
"Add link": "링크 추가",
|
||||||
"Please enter a valid url": "유효한 URL을 입력하세요",
|
"Please enter a valid url": "유효한 URL을 입력하세요",
|
||||||
@ -296,11 +298,11 @@
|
|||||||
"Heading 2": "제목 2",
|
"Heading 2": "제목 2",
|
||||||
"Heading 3": "제목 3",
|
"Heading 3": "제목 3",
|
||||||
"To-do List": "할 일 목록",
|
"To-do List": "할 일 목록",
|
||||||
"Bullet List": "글머리 기호 목록",
|
"Bullet List": "글머리 표",
|
||||||
"Numbered List": "번호 매기기 목록",
|
"Numbered List": "문단 번호",
|
||||||
"Blockquote": "인용구",
|
"Blockquote": "인용구",
|
||||||
"Just start typing with plain text.": "일반 텍스트로 입력을 시작하세요.",
|
"Just start typing with plain text.": "일반 텍스트로 입력을 시작하세요.",
|
||||||
"Track tasks with a to-do list.": "할 일 목록으로 작업을 추적하세요.",
|
"Track tasks with a to-do list.": "할 일 목록으로 작업을 정리하세요.",
|
||||||
"Big section heading.": "대제목.",
|
"Big section heading.": "대제목.",
|
||||||
"Medium section heading.": "중제목.",
|
"Medium section heading.": "중제목.",
|
||||||
"Small section heading.": "소제목.",
|
"Small section heading.": "소제목.",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
||||||
"Names do not match": "이름이 일치하지 않습니다",
|
"Names do not match": "이름이 일치하지 않습니다",
|
||||||
"Today, {{time}}": "오늘, {{time}}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
390
apps/client/public/locales/nl-NL/translation.json
Normal file
390
apps/client/public/locales/nl-NL/translation.json
Normal 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"
|
||||||
|
}
|
||||||
@ -148,6 +148,7 @@
|
|||||||
"Select role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",
|
"Select role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",
|
||||||
"Select theme": "Selecionar tema",
|
"Select theme": "Selecionar tema",
|
||||||
"Send invitation": "Enviar convite",
|
"Send invitation": "Enviar convite",
|
||||||
|
"Invitation sent": "Convite enviado",
|
||||||
"Settings": "Configurações",
|
"Settings": "Configurações",
|
||||||
"Setup workspace": "Configurar workspace",
|
"Setup workspace": "Configurar workspace",
|
||||||
"Sign In": "Entrar",
|
"Sign In": "Entrar",
|
||||||
@ -244,6 +245,7 @@
|
|||||||
"Align left": "Alinhar à esquerda",
|
"Align left": "Alinhar à esquerda",
|
||||||
"Align right": "Alinhar à direita",
|
"Align right": "Alinhar à direita",
|
||||||
"Align center": "Alinhar ao centro",
|
"Align center": "Alinhar ao centro",
|
||||||
|
"Justify": "Justificar",
|
||||||
"Merge cells": "Mesclar células",
|
"Merge cells": "Mesclar células",
|
||||||
"Split cell": "Dividir célula",
|
"Split cell": "Dividir célula",
|
||||||
"Delete column": "Excluir coluna",
|
"Delete column": "Excluir coluna",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
||||||
"Names do not match": "Os nomes não coincidem",
|
"Names do not match": "Os nomes não coincidem",
|
||||||
"Today, {{time}}": "Hoje, {{time}}",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 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 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.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
"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 create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||||
"Can edit": "Может изменять",
|
"Can edit": "Может изменять",
|
||||||
"Can manage workspace": "Может управлять рабочим пространством",
|
"Can manage workspace": "Может управлять рабочей областью",
|
||||||
"Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
|
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить",
|
||||||
"Can view": "Может просматривать",
|
"Can view": "Может просматривать",
|
||||||
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
||||||
"Cancel": "Отменить",
|
"Cancel": "Отменить",
|
||||||
@ -34,7 +34,7 @@
|
|||||||
"Create group": "Создать группу",
|
"Create group": "Создать группу",
|
||||||
"Create page": "Создать страницу",
|
"Create page": "Создать страницу",
|
||||||
"Create space": "Создать пространство",
|
"Create space": "Создать пространство",
|
||||||
"Create workspace": "Создать рабочее пространство",
|
"Create workspace": "Создать рабочую область",
|
||||||
"Current password": "Текущий пароль",
|
"Current password": "Текущий пароль",
|
||||||
"Dark": "Темная",
|
"Dark": "Темная",
|
||||||
"Date": "Дата",
|
"Date": "Дата",
|
||||||
@ -82,7 +82,7 @@
|
|||||||
"Groups": "Группы",
|
"Groups": "Группы",
|
||||||
"Has full access to space settings and pages.": "Имеет полный доступ к настройкам пространства и страницам.",
|
"Has full access to space settings and pages.": "Имеет полный доступ к настройкам пространства и страницам.",
|
||||||
"Home": "Главная",
|
"Home": "Главная",
|
||||||
"Import pages": "Импортировать страницы",
|
"Import pages": "Импорт страниц",
|
||||||
"Import pages & space settings": "Импорт страниц и настройки пространства",
|
"Import pages & space settings": "Импорт страниц и настройки пространства",
|
||||||
"Importing pages": "Импортирование страниц",
|
"Importing pages": "Импортирование страниц",
|
||||||
"invalid invitation link": "ссылка на приглашение недействительна",
|
"invalid invitation link": "ссылка на приглашение недействительна",
|
||||||
@ -92,7 +92,7 @@
|
|||||||
"Invite new members": "Пригласить новых участников",
|
"Invite new members": "Пригласить новых участников",
|
||||||
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
||||||
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
||||||
"Join the workspace": "Присоединиться к рабочему пространству",
|
"Join the workspace": "Присоединиться к рабочей области",
|
||||||
"Language": "Язык",
|
"Language": "Язык",
|
||||||
"Light": "Светлая",
|
"Light": "Светлая",
|
||||||
"Link copied": "Ссылка скопирована",
|
"Link copied": "Ссылка скопирована",
|
||||||
@ -128,7 +128,7 @@
|
|||||||
"Password changed successfully": "Пароль успешно изменён",
|
"Password changed successfully": "Пароль успешно изменён",
|
||||||
"Pending": "В ожидании",
|
"Pending": "В ожидании",
|
||||||
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
|
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
|
||||||
"Preferences": "Внешний вид",
|
"Preferences": "Настройки",
|
||||||
"Print PDF": "Печать PDF",
|
"Print PDF": "Печать PDF",
|
||||||
"Profile": "Профиль",
|
"Profile": "Профиль",
|
||||||
"Recently updated": "Обновлено недавно",
|
"Recently updated": "Обновлено недавно",
|
||||||
@ -148,8 +148,9 @@
|
|||||||
"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": "Приглашение отправлено",
|
||||||
"Settings": "Настройки",
|
"Settings": "Настройки",
|
||||||
"Setup workspace": "Настроить рабочее пространство",
|
"Setup workspace": "Настроить рабочую область",
|
||||||
"Sign In": "Вход",
|
"Sign In": "Вход",
|
||||||
"Sign Up": "Регистрация",
|
"Sign Up": "Регистрация",
|
||||||
"Slug": "Slug",
|
"Slug": "Slug",
|
||||||
@ -176,9 +177,9 @@
|
|||||||
"Untitled": "Без названия",
|
"Untitled": "Без названия",
|
||||||
"Updated successfully": "Обновлено успешно",
|
"Updated successfully": "Обновлено успешно",
|
||||||
"User": "Пользователь",
|
"User": "Пользователь",
|
||||||
"Workspace": "Рабочее пространство",
|
"Workspace": "Рабочая область",
|
||||||
"Workspace Name": "Имя рабочего пространства",
|
"Workspace Name": "Имя рабочей области",
|
||||||
"Workspace settings": "Настройки рабочего пространства",
|
"Workspace settings": "Настройки рабочей области",
|
||||||
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
||||||
"Your Email": "Ваш адрес электронной почты",
|
"Your Email": "Ваш адрес электронной почты",
|
||||||
"Your import is complete.": "Ваш импорт завершен.",
|
"Your import is complete.": "Ваш импорт завершен.",
|
||||||
@ -216,9 +217,9 @@
|
|||||||
"Revoke invitation": "Отозвать приглашение",
|
"Revoke invitation": "Отозвать приглашение",
|
||||||
"Revoke": "Отозвать",
|
"Revoke": "Отозвать",
|
||||||
"Don't": "Нет",
|
"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": "Отправить приглашение повторно",
|
"Resend invitation": "Отправить приглашение повторно",
|
||||||
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
|
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
|
||||||
"Invite link": "Ссылка для приглашения",
|
"Invite link": "Ссылка для приглашения",
|
||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
@ -244,12 +245,13 @@
|
|||||||
"Align left": "По левому краю",
|
"Align left": "По левому краю",
|
||||||
"Align right": "По правому краю",
|
"Align right": "По правому краю",
|
||||||
"Align center": "По центру",
|
"Align center": "По центру",
|
||||||
|
"Justify": "По ширине",
|
||||||
"Merge cells": "Объединить ячейки",
|
"Merge cells": "Объединить ячейки",
|
||||||
"Split cell": "Разделить ячейку",
|
"Split cell": "Разделить ячейку",
|
||||||
"Delete column": "Удалить столбец",
|
"Delete column": "Удалить столбец",
|
||||||
"Delete row": "Удалить строку",
|
"Delete row": "Удалить строку",
|
||||||
"Add left column": "Добавить левый столбец",
|
"Add left column": "Добавить столбец слева",
|
||||||
"Add right column": "Добавить правый столбец",
|
"Add right column": "Добавить столбец справа",
|
||||||
"Add row above": "Добавить строку выше",
|
"Add row above": "Добавить строку выше",
|
||||||
"Add row below": "Добавить строку ниже",
|
"Add row below": "Добавить строку ниже",
|
||||||
"Delete table": "Удалить таблицу",
|
"Delete table": "Удалить таблицу",
|
||||||
@ -320,23 +322,69 @@
|
|||||||
"Quote": "Цитата",
|
"Quote": "Цитата",
|
||||||
"Image": "Изображение",
|
"Image": "Изображение",
|
||||||
"File attachment": "Прикрепленный файл",
|
"File attachment": "Прикрепленный файл",
|
||||||
"Toggle block": "Переключить блок",
|
"Toggle block": "Сворачиваемый блок",
|
||||||
"Callout": "Выноска",
|
"Callout": "Выноска",
|
||||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||||
"Math inline": "Формула в строке",
|
"Math inline": "Формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
"Math block": "Блок формул",
|
"Math block": "Блок формул",
|
||||||
"Insert math equation": "Вставить математическое выражение",
|
"Insert math equation": "Вставить математическое выражение",
|
||||||
"Mermaid diagram": "Диаграмма Mermaid",
|
"Mermaid diagram": "Диаграмма Mermaid",
|
||||||
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
|
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
|
||||||
"Insert and design Drawio diagrams": "Вставьте и редактируйте диаграммы Draw.io",
|
"Insert and design Drawio diagrams": "Вставить и рисовать диаграммы Draw.io",
|
||||||
"Insert current date": "Вставить текущую дату",
|
"Insert current date": "Вставить текущую дату",
|
||||||
"Draw and sketch excalidraw diagrams": "Создайте и рисуйте диаграммы Excalidraw",
|
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
|
||||||
"Multiple": "Несколько",
|
"Multiple": "Несколько",
|
||||||
"Heading {{level}}": "Заголовок {{level}}",
|
"Heading {{level}}": "Заголовок {{level}}",
|
||||||
"Toggle title": "Переключить заголовок",
|
"Toggle title": "Переключить заголовок",
|
||||||
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для выбора команд",
|
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
||||||
"Names do not match": "Названия не совпадают",
|
"Names do not match": "Названия не совпадают",
|
||||||
"Today, {{time}}": "Сегодня, {{time}}",
|
"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": "Страница успешно скопирована"
|
||||||
}
|
}
|
||||||
|
|||||||
390
apps/client/public/locales/uk-UA/translation.json
Normal file
390
apps/client/public/locales/uk-UA/translation.json
Normal 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": "Сторінку успішно скопійовано"
|
||||||
|
}
|
||||||
@ -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": "邀请邮件已发送",
|
||||||
"Settings": "设置",
|
"Settings": "设置",
|
||||||
"Setup workspace": "设置工作空间",
|
"Setup workspace": "设置工作空间",
|
||||||
"Sign In": "登录",
|
"Sign In": "登录",
|
||||||
@ -244,6 +245,7 @@
|
|||||||
"Align left": "靠左对齐",
|
"Align left": "靠左对齐",
|
||||||
"Align right": "靠右对齐",
|
"Align right": "靠右对齐",
|
||||||
"Align center": "居中对齐",
|
"Align center": "居中对齐",
|
||||||
|
"Justify": "两端对齐",
|
||||||
"Merge cells": "合并单元格",
|
"Merge cells": "合并单元格",
|
||||||
"Split cell": "分割单元格",
|
"Split cell": "分割单元格",
|
||||||
"Delete column": "删除整列",
|
"Delete column": "删除整列",
|
||||||
@ -296,7 +298,7 @@
|
|||||||
"Heading 2": "2 级标题",
|
"Heading 2": "2 级标题",
|
||||||
"Heading 3": "3 级标题",
|
"Heading 3": "3 级标题",
|
||||||
"To-do List": "代办列表",
|
"To-do List": "代办列表",
|
||||||
"Bullet List": "无需列表",
|
"Bullet List": "无序列表",
|
||||||
"Numbered List": "有序列表",
|
"Numbered List": "有序列表",
|
||||||
"Blockquote": "引用块",
|
"Blockquote": "引用块",
|
||||||
"Just start typing with plain text.": "只需开始键入纯文本",
|
"Just start typing with plain text.": "只需开始键入纯文本",
|
||||||
@ -338,5 +340,51 @@
|
|||||||
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
||||||
"Names do not match": "名称不匹配",
|
"Names do not match": "名称不匹配",
|
||||||
"Today, {{time}}": "今天,{{time}}",
|
"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": "页面复制成功"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,10 +18,24 @@ import { ErrorBoundary } from "react-error-boundary";
|
|||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
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 { 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() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useRedirectToCloudSelect();
|
||||||
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -29,15 +43,30 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
|
||||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
<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 path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||||
|
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path={"/home"} element={<Home />} />
|
<Route path={"/home"} element={<Home />} />
|
||||||
|
|
||||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||||
<Route
|
<Route
|
||||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||||
@ -61,6 +90,10 @@ export default function App() {
|
|||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<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>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
31
apps/client/src/components/common/copy.tsx
Normal file
31
apps/client/src/components/common/copy.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -65,11 +65,12 @@ export default function ExportModal({
|
|||||||
yOffset="10vh"
|
yOffset="10vh"
|
||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header py={0}>
|
<Modal.Header py={0}>
|
||||||
<Modal.Title fw={500}>Export {type}</Modal.Title>
|
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export default function Paginate({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group mt="md">
|
<Group mt="md" justify="flex-end">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
|
|||||||
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/client/src/components/icons/google-icon.tsx
Normal file
33
apps/client/src/components/icons/google-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/components/icons/openid-icon.tsx
Normal file
20
apps/client/src/components/icons/openid-icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 classes from "./app-header.module.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
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 APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import {useAtom} from "jotai/index";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} 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 SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
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() {
|
export function AppHeader() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -22,6 +24,7 @@ export function AppHeader() {
|
|||||||
|
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||||
|
const { isTrial, trialDaysLeft } = useTrial();
|
||||||
|
|
||||||
const isHomeRoute = location.pathname.startsWith("/home");
|
const isHomeRoute = location.pathname.startsWith("/home");
|
||||||
|
|
||||||
@ -38,7 +41,6 @@ export function AppHeader() {
|
|||||||
{!isHomeRoute && (
|
{!isHomeRoute && (
|
||||||
<>
|
<>
|
||||||
<Tooltip label={t("Sidebar toggle")}>
|
<Tooltip label={t("Sidebar toggle")}>
|
||||||
|
|
||||||
<SidebarToggle
|
<SidebarToggle
|
||||||
aria-label={t("Sidebar toggle")}
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={mobileOpened}
|
opened={mobileOpened}
|
||||||
@ -63,7 +65,7 @@ export function AppHeader() {
|
|||||||
<Text
|
<Text
|
||||||
size="lg"
|
size="lg"
|
||||||
fw={600}
|
fw={600}
|
||||||
style={{cursor: "pointer", userSelect: "none"}}
|
style={{ cursor: "pointer", userSelect: "none" }}
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/home"
|
to="/home"
|
||||||
>
|
>
|
||||||
@ -75,8 +77,21 @@ export function AppHeader() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group px={"xl"}>
|
<Group px={"xl"} wrap="nowrap">
|
||||||
<TopMenu/>
|
{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>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -4,10 +4,14 @@ import { useAtom } from "jotai";
|
|||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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() {
|
export default function Aside() {
|
||||||
const [{ tab }] = useAtom(asideStateAtom);
|
const [{ tab }] = useAtom(asideStateAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let component: ReactNode;
|
let component: ReactNode;
|
||||||
@ -17,6 +21,10 @@ export default function Aside() {
|
|||||||
component = <CommentList />;
|
component = <CommentList />;
|
||||||
title = "Comments";
|
title = "Comments";
|
||||||
break;
|
break;
|
||||||
|
case "toc":
|
||||||
|
component = <TableOfContents editor={pageEditor} />;
|
||||||
|
title = "Table of contents";
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
component = null;
|
component = null;
|
||||||
title = null;
|
title = null;
|
||||||
|
|||||||
@ -1,24 +1,29 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
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 { useLocation } from "react-router-dom";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
asideStateAtom,
|
asideStateAtom,
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom, sidebarWidthAtom,
|
mobileSidebarAtom,
|
||||||
|
sidebarWidthAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
import Aside from "@/components/layouts/global/aside.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
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({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
useTrialEndAction();
|
||||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||||
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||||
@ -37,7 +42,9 @@ export default function GlobalAppShell({
|
|||||||
const resize = React.useCallback(
|
const resize = React.useCallback(
|
||||||
(mouseMoveEvent) => {
|
(mouseMoveEvent) => {
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
|
const newWidth =
|
||||||
|
mouseMoveEvent.clientX -
|
||||||
|
sidebarRef.current.getBoundingClientRect().left;
|
||||||
if (newWidth < 220) {
|
if (newWidth < 220) {
|
||||||
setSidebarWidth(220);
|
setSidebarWidth(220);
|
||||||
return;
|
return;
|
||||||
@ -49,7 +56,7 @@ export default function GlobalAppShell({
|
|||||||
setSidebarWidth(newWidth);
|
setSidebarWidth(newWidth);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isResizing]
|
[isResizing],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -94,7 +101,11 @@ export default function GlobalAppShell({
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
{!isHomeRoute && (
|
{!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} />
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||||
{isSpaceRoute && <SpaceSidebar />}
|
{isSpaceRoute && <SpaceSidebar />}
|
||||||
{isSettingsRoute && <SettingsSidebar />}
|
{isSettingsRoute && <SettingsSidebar />}
|
||||||
@ -102,7 +113,7 @@ export default function GlobalAppShell({
|
|||||||
)}
|
)}
|
||||||
<AppShell.Main>
|
<AppShell.Main>
|
||||||
{isSettingsRoute ? (
|
{isSettingsRoute ? (
|
||||||
<Container size={800}>{children}</Container>
|
<Container size={850}>{children}</Container>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -20,4 +20,4 @@ export const asideStateAtom = atom<AsideStateType>({
|
|||||||
isAsideOpen: false,
|
isAsideOpen: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||||
@ -33,13 +33,13 @@ export default function TopMenu() {
|
|||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Group gap={7} wrap={"nowrap"}>
|
<Group gap={7} wrap={"nowrap"}>
|
||||||
<CustomAvatar
|
<CustomAvatar
|
||||||
avatarUrl={workspace.logo}
|
avatarUrl={workspace?.logo}
|
||||||
name={workspace.name}
|
name={workspace?.name}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||||
{workspace.name}
|
{workspace?.name}
|
||||||
</Text>
|
</Text>
|
||||||
<IconChevronDown size={16} />
|
<IconChevronDown size={16} />
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
59
apps/client/src/components/settings/app-version.tsx
Normal file
59
apps/client/src/components/settings/app-version.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
67
apps/client/src/components/settings/settings-queries.tsx
Normal file
67
apps/client/src/components/settings/settings-queries.tsx
Normal 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 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
|
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconUser,
|
IconUser,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@ -8,15 +8,40 @@ import {
|
|||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconSpaces,
|
IconSpaces,
|
||||||
IconBrush,
|
IconBrush,
|
||||||
|
IconCoin,
|
||||||
|
IconLock,
|
||||||
|
IconKey,
|
||||||
|
IconWorld,
|
||||||
} from "@tabler/icons-react";
|
} 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 classes from "./settings.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
interface DataItem {
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
path: string;
|
path: string;
|
||||||
|
isCloud?: boolean;
|
||||||
|
isEnterprise?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
isSelfhosted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataGroup {
|
interface DataGroup {
|
||||||
@ -45,8 +70,34 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconUsers,
|
icon: IconUsers,
|
||||||
path: "/settings/members",
|
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: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ 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 { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState(location.pathname);
|
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(() => {
|
useEffect(() => {
|
||||||
setActive(location.pathname);
|
setActive(location.pathname);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
const menuItems = groupedData.map((group) => (
|
const canShowItem = (item: DataItem) => {
|
||||||
<div key={group.heading}>
|
if (item.isCloud && item.isEnterprise) {
|
||||||
<Text c="dimmed" className={classes.linkHeader}>
|
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
||||||
{t(group.heading)}
|
return item.isAdmin ? isAdmin : true;
|
||||||
</Text>
|
}
|
||||||
{group.items.map((item) => (
|
|
||||||
<Link
|
if (item.isCloud) {
|
||||||
className={classes.link}
|
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
|
||||||
data-active={active.startsWith(item.path) || undefined}
|
}
|
||||||
key={item.label}
|
|
||||||
to={item.path}
|
if (item.isSelfhosted) {
|
||||||
>
|
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
|
||||||
<item.icon className={classes.linkIcon} stroke={2} />
|
}
|
||||||
<span>{t(item.label)}</span>
|
|
||||||
</Link>
|
if (item.isEnterprise) {
|
||||||
))}
|
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
|
||||||
</div>
|
}
|
||||||
));
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={classes.navbar}>
|
<div className={classes.navbar}>
|
||||||
<Group className={classes.title} justify="flex-start">
|
<Group className={classes.title} justify="flex-start">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => {
|
||||||
|
goBack();
|
||||||
|
if (mobileSidebarOpened) {
|
||||||
|
toggleMobileSidebar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
c="gray"
|
c="gray"
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
@ -95,18 +227,21 @@ export default function SettingsSidebar() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||||
<div className={classes.version}>
|
|
||||||
<Text
|
{!isCloud() && <AppVersion />}
|
||||||
className={classes.version}
|
|
||||||
size="sm"
|
{isCloud() && (
|
||||||
c="dimmed"
|
<div className={classes.text}>
|
||||||
component="a"
|
<Text
|
||||||
href="https://github.com/docmost/docmost/releases"
|
size="sm"
|
||||||
target="_blank"
|
c="dimmed"
|
||||||
>
|
component="a"
|
||||||
v{APP_VERSION}
|
href="mailto:help@docmost.com"
|
||||||
</Text>
|
>
|
||||||
</div>
|
help@docmost.com
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.text {
|
||||||
padding-left: var(--mantine-spacing-xs) ;
|
padding-left: var(--mantine-spacing-xs) ;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/client/src/components/theme-toggle.module.css
Normal file
19
apps/client/src/components/theme-toggle.module.css
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.dark {
|
||||||
|
@mixin dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
@mixin light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() {
|
export function ThemeToggle() {
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="center" mt="xl">
|
<Tooltip label="Toggle Color Scheme">
|
||||||
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
<ActionIcon
|
||||||
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
variant="default"
|
||||||
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
onClick={() => {
|
||||||
</Group>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,8 +68,8 @@ function EmojiPicker({
|
|||||||
{icon}
|
{icon}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</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
|
<Picker
|
||||||
data={async () => (await import("@emoji-mart/data")).default}
|
data={async () => (await import("@emoji-mart/data")).default}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
@ -77,22 +77,22 @@ function EmojiPicker({
|
|||||||
skinTonePosition="search"
|
skinTonePosition="search"
|
||||||
theme={colorScheme}
|
theme={colorScheme}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
<Button
|
||||||
<Button
|
variant="default"
|
||||||
variant="default"
|
c="gray"
|
||||||
c="gray"
|
size="xs"
|
||||||
size="xs"
|
style={{
|
||||||
style={{
|
position: "absolute",
|
||||||
position: "absolute",
|
zIndex: 2,
|
||||||
zIndex: 2,
|
bottom: "1rem",
|
||||||
bottom: "1rem",
|
right: "1rem",
|
||||||
right: "1rem",
|
}}
|
||||||
}}
|
onClick={handleRemoveEmoji}
|
||||||
onClick={handleRemoveEmoji}
|
>
|
||||||
>
|
{t("Remove")}
|
||||||
{t("Remove")}
|
</Button>
|
||||||
</Button>
|
</Popover.Dropdown>
|
||||||
</Popover.Dropdown>
|
</Suspense>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/client/src/ee/LICENSE
Normal file
1
apps/client/src/ee/LICENSE
Normal file
@ -0,0 +1 @@
|
|||||||
|
Files in this directory are subject to the Docmost Enterprise Edition license.
|
||||||
170
apps/client/src/ee/billing/components/billing-details.tsx
Normal file
170
apps/client/src/ee/billing/components/billing-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/client/src/ee/billing/components/billing-incomplete.tsx
Normal file
13
apps/client/src/ee/billing/components/billing-incomplete.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
apps/client/src/ee/billing/components/billing-plans.tsx
Normal file
188
apps/client/src/ee/billing/components/billing-plans.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/client/src/ee/billing/components/billing-trial.tsx
Normal file
32
apps/client/src/ee/billing/components/billing-trial.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/client/src/ee/billing/components/billing.module.css
Normal file
10
apps/client/src/ee/billing/components/billing.module.css
Normal 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);
|
||||||
|
}
|
||||||
34
apps/client/src/ee/billing/components/manage-billing.tsx
Normal file
34
apps/client/src/ee/billing/components/manage-billing.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/client/src/ee/billing/pages/billing.tsx
Normal file
41
apps/client/src/ee/billing/pages/billing.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/client/src/ee/billing/queries/billing-query.ts
Normal file
20
apps/client/src/ee/billing/queries/billing-query.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
29
apps/client/src/ee/billing/services/billing-service.ts
Normal file
29
apps/client/src/ee/billing/services/billing-service.ts
Normal 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;
|
||||||
|
}
|
||||||
64
apps/client/src/ee/billing/types/billing.types.ts
Normal file
64
apps/client/src/ee/billing/types/billing.types.ts
Normal 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;
|
||||||
|
}
|
||||||
17
apps/client/src/ee/billing/utils.ts
Normal file
17
apps/client/src/ee/billing/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
13
apps/client/src/ee/cloud/query/cloud-query.ts
Normal file
13
apps/client/src/ee/cloud/query/cloud-query.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
7
apps/client/src/ee/cloud/service/cloud-service.ts
Normal file
7
apps/client/src/ee/cloud/service/cloud-service.ts
Normal 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;
|
||||||
|
}
|
||||||
96
apps/client/src/ee/components/cloud-login-form.tsx
Normal file
96
apps/client/src/ee/components/cloud-login-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
apps/client/src/ee/components/joined-workspaces.module.css
Normal file
13
apps/client/src/ee/components/joined-workspaces.module.css
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
51
apps/client/src/ee/components/joined-workspaces.tsx
Normal file
51
apps/client/src/ee/components/joined-workspaces.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
apps/client/src/ee/components/manage-hostname.tsx
Normal file
119
apps/client/src/ee/components/manage-hostname.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/client/src/ee/components/sso-cloud-signup.tsx
Normal file
25
apps/client/src/ee/components/sso-cloud-signup.tsx
Normal 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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/client/src/ee/components/sso-login.tsx
Normal file
57
apps/client/src/ee/components/sso-login.tsx
Normal 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" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/client/src/ee/hooks/use-license.tsx
Normal file
9
apps/client/src/ee/hooks/use-license.tsx
Normal 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;
|
||||||
19
apps/client/src/ee/hooks/use-plan.tsx
Normal file
19
apps/client/src/ee/hooks/use-plan.tsx
Normal 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;
|
||||||
20
apps/client/src/ee/hooks/use-redirect-to-cloud-select.tsx
Normal file
20
apps/client/src/ee/hooks/use-redirect-to-cloud-select.tsx
Normal 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]);
|
||||||
|
};
|
||||||
36
apps/client/src/ee/hooks/use-trial-end-action.tsx
Normal file
36
apps/client/src/ee/hooks/use-trial-end-action.tsx
Normal 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]);
|
||||||
|
};
|
||||||
16
apps/client/src/ee/hooks/use-trial.tsx
Normal file
16
apps/client/src/ee/hooks/use-trial.tsx
Normal 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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/client/src/ee/licence/components/license-details.tsx
Normal file
81
apps/client/src/ee/licence/components/license-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export default function LicenseMessage() {
|
||||||
|
return <>To unlock enterprise features, please contact sales@docmost.com to purchase a license.</>;
|
||||||
|
}
|
||||||
39
apps/client/src/ee/licence/components/oss-details.tsx
Normal file
39
apps/client/src/ee/licence/components/oss-details.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/client/src/ee/licence/components/remove-license.tsx
Normal file
33
apps/client/src/ee/licence/components/remove-license.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
26
apps/client/src/ee/licence/license.utils.ts
Normal file
26
apps/client/src/ee/licence/license.utils.ts
Normal 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;
|
||||||
|
}
|
||||||
35
apps/client/src/ee/licence/pages/license.tsx
Normal file
35
apps/client/src/ee/licence/pages/license.tsx
Normal 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 />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/client/src/ee/licence/queries/license-query.ts
Normal file
52
apps/client/src/ee/licence/queries/license-query.ts
Normal 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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
18
apps/client/src/ee/licence/services/license-service.ts
Normal file
18
apps/client/src/ee/licence/services/license-service.ts
Normal 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");
|
||||||
|
}
|
||||||
8
apps/client/src/ee/licence/types/license.types.ts
Normal file
8
apps/client/src/ee/licence/types/license.types.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface ILicenseInfo {
|
||||||
|
id: string;
|
||||||
|
customerName: string;
|
||||||
|
seatCount: number;
|
||||||
|
issuedAt: Date;
|
||||||
|
expiresAt: Date;
|
||||||
|
trial: boolean;
|
||||||
|
}
|
||||||
20
apps/client/src/ee/pages/cloud-login.tsx
Normal file
20
apps/client/src/ee/pages/cloud-login.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/client/src/ee/pages/create-workspace.tsx
Normal file
15
apps/client/src/ee/pages/create-workspace.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/client/src/ee/security/components/allowed-domains.tsx
Normal file
88
apps/client/src/ee/security/components/allowed-domains.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/client/src/ee/security/components/enforce-sso.tsx
Normal file
61
apps/client/src/ee/security/components/enforce-sso.tsx
Normal 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")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/client/src/ee/security/components/sso-google-form.tsx
Normal file
91
apps/client/src/ee/security/components/sso-google-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
apps/client/src/ee/security/components/sso-oidc-form.tsx
Normal file
140
apps/client/src/ee/security/components/sso-oidc-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
apps/client/src/ee/security/components/sso-provider-list.tsx
Normal file
186
apps/client/src/ee/security/components/sso-provider-list.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/client/src/ee/security/components/sso-saml-form.tsx
Normal file
153
apps/client/src/ee/security/components/sso-saml-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/client/src/ee/security/components/sso.module.css
Normal file
14
apps/client/src/ee/security/components/sso.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/client/src/ee/security/contants.ts
Normal file
5
apps/client/src/ee/security/contants.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum SSO_PROVIDER {
|
||||||
|
OIDC = 'oidc',
|
||||||
|
SAML = 'saml',
|
||||||
|
GOOGLE = 'google',
|
||||||
|
}
|
||||||
52
apps/client/src/ee/security/pages/security.tsx
Normal file
52
apps/client/src/ee/security/pages/security.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/client/src/ee/security/queries/security-query.ts
Normal file
88
apps/client/src/ee/security/queries/security-query.ts
Normal 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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
32
apps/client/src/ee/security/services/security-service.ts
Normal file
32
apps/client/src/ee/security/services/security-service.ts
Normal 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;
|
||||||
|
}
|
||||||
39
apps/client/src/ee/security/sso.utils.ts
Normal file
39
apps/client/src/ee/security/sso.utils.ts
Normal 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`;
|
||||||
|
}
|
||||||
20
apps/client/src/ee/security/types/security.types.ts
Normal file
20
apps/client/src/ee/security/types/security.types.ts
Normal 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;
|
||||||
|
}
|
||||||
16
apps/client/src/ee/utils.ts
Normal file
16
apps/client/src/ee/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -2,4 +2,17 @@
|
|||||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
|
box-shadow: rgba(0, 0, 0, 0.07) 0px 2px 45px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -35,8 +35,8 @@ export function ForgotPasswordForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Forgot password")}
|
{t("Forgot password")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import classes from "@/features/auth/components/auth.module.css";
|
|||||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import SsoLogin from "@/ee/components/sso-login.tsx";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
@ -65,45 +66,49 @@ export function InviteSignUpForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Join the workspace")}
|
{t("Join the workspace")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<Stack align="stretch" justify="center" gap="xl">
|
<SsoLogin />
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
|
||||||
<TextInput
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
label={t("Name")}
|
|
||||||
placeholder={t("enter your full name")}
|
|
||||||
variant="filled"
|
|
||||||
{...form.getInputProps("name")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
{!invitation.enforceSso && (
|
||||||
id="email"
|
<Stack align="stretch" justify="center" gap="xl">
|
||||||
type="email"
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
label={t("Email")}
|
<TextInput
|
||||||
value={invitation.email}
|
id="name"
|
||||||
disabled
|
type="text"
|
||||||
variant="filled"
|
label={t("Name")}
|
||||||
mt="md"
|
placeholder={t("enter your full name")}
|
||||||
/>
|
variant="filled"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<TextInput
|
||||||
label={t("Password")}
|
id="email"
|
||||||
placeholder={t("Your password")}
|
type="email"
|
||||||
variant="filled"
|
label={t("Email")}
|
||||||
mt="md"
|
value={invitation.email}
|
||||||
{...form.getInputProps("password")}
|
disabled
|
||||||
/>
|
variant="filled"
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
mt="md"
|
||||||
{t("Sign Up")}
|
/>
|
||||||
</Button>
|
|
||||||
</form>
|
<PasswordInput
|
||||||
</Stack>
|
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>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,12 +10,17 @@ import {
|
|||||||
PasswordInput,
|
PasswordInput,
|
||||||
Box,
|
Box,
|
||||||
Anchor,
|
Anchor,
|
||||||
|
Group,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import classes from "./auth.module.css";
|
import classes from "./auth.module.css";
|
||||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
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 APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
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({
|
const formSchema = z.object({
|
||||||
email: z
|
email: z
|
||||||
@ -29,6 +34,12 @@ export function LoginForm() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { signIn, isLoading } = useAuth();
|
const { signIn, isLoading } = useAuth();
|
||||||
useRedirectIfAuthenticated();
|
useRedirectIfAuthenticated();
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading: isDataLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useWorkspacePublicDataQuery();
|
||||||
|
|
||||||
const form = useForm<ILogin>({
|
const form = useForm<ILogin>({
|
||||||
validate: zodResolver(formSchema),
|
validate: zodResolver(formSchema),
|
||||||
@ -42,44 +53,60 @@ export function LoginForm() {
|
|||||||
await signIn(data);
|
await signIn(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isDataLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError && error?.["response"]?.status === 404) {
|
||||||
|
return <Error404 />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Login")}
|
{t("Login")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<SsoLogin />
|
||||||
<TextInput
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
label={t("Email")}
|
|
||||||
placeholder="email@example.com"
|
|
||||||
variant="filled"
|
|
||||||
{...form.getInputProps("email")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordInput
|
{!data?.enforceSso && (
|
||||||
label={t("Password")}
|
<>
|
||||||
placeholder={t("Your password")}
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
variant="filled"
|
<TextInput
|
||||||
mt="md"
|
id="email"
|
||||||
{...form.getInputProps("password")}
|
type="email"
|
||||||
/>
|
label={t("Email")}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
variant="filled"
|
||||||
|
{...form.getInputProps("email")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
<PasswordInput
|
||||||
{t("Sign In")}
|
label={t("Password")}
|
||||||
</Button>
|
placeholder={t("Your password")}
|
||||||
</form>
|
variant="filled"
|
||||||
|
mt="md"
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Anchor
|
<Group justify="flex-end" mt="sm">
|
||||||
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
|
<Anchor
|
||||||
component={Link}
|
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
|
||||||
underline="never"
|
component={Link}
|
||||||
size="sm"
|
underline="never"
|
||||||
>
|
size="sm"
|
||||||
{t("Forgot your password?")}
|
>
|
||||||
</Anchor>
|
{t("Forgot your password?")}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button type="submit" fullWidth mt="md" loading={isLoading}>
|
||||||
|
{t("Sign In")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,8 +37,8 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<Container size={420} className={classes.container}>
|
||||||
<Box p="xl" mt={200}>
|
<Box p="xl" className={classes.containerBox}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
{t("Password reset")}
|
{t("Password reset")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm, zodResolver } from "@mantine/form";
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
@ -9,14 +8,20 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Box,
|
Box,
|
||||||
|
Anchor,
|
||||||
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||||
import useAuth from "@/features/auth/hooks/use-auth";
|
import useAuth from "@/features/auth/hooks/use-auth";
|
||||||
import classes from "@/features/auth/components/auth.module.css";
|
import classes from "@/features/auth/components/auth.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
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({
|
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),
|
name: z.string().min(1).max(50),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@ -45,55 +50,73 @@ export function SetupWorkspaceForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={40} className={classes.container}>
|
<div>
|
||||||
<Box p="xl" mt={200}>
|
<Container size={420} className={classes.container}>
|
||||||
<Title order={2} ta="center" fw={500} mb="md">
|
<Box p="xl" className={classes.containerBox}>
|
||||||
{t("Create workspace")}
|
<Title order={2} ta="center" fw={500} mb="md">
|
||||||
</Title>
|
{t("Create workspace")}
|
||||||
|
</Title>
|
||||||
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
{isCloud() && <SsoCloudSignup />}
|
||||||
<TextInput
|
|
||||||
id="workspaceName"
|
|
||||||
type="text"
|
|
||||||
label={t("Workspace Name")}
|
|
||||||
placeholder={t("e.g ACME Inc")}
|
|
||||||
variant="filled"
|
|
||||||
mt="md"
|
|
||||||
{...form.getInputProps("workspaceName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||||
id="name"
|
{!isCloud() && (
|
||||||
type="text"
|
<TextInput
|
||||||
label={t("Your Name")}
|
id="workspaceName"
|
||||||
placeholder={t("enter your full name")}
|
type="text"
|
||||||
variant="filled"
|
label={t("Workspace Name")}
|
||||||
mt="md"
|
placeholder={t("e.g ACME Inc")}
|
||||||
{...form.getInputProps("name")}
|
variant="filled"
|
||||||
/>
|
mt="md"
|
||||||
|
{...form.getInputProps("workspaceName")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
id="email"
|
id="name"
|
||||||
type="email"
|
type="text"
|
||||||
label={t("Your Email")}
|
label={t("Your Name")}
|
||||||
placeholder="email@example.com"
|
placeholder={t("enter your full name")}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
mt="md"
|
mt="md"
|
||||||
{...form.getInputProps("email")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<TextInput
|
||||||
label={t("Password")}
|
id="email"
|
||||||
placeholder={t("Enter a strong password")}
|
type="email"
|
||||||
variant="filled"
|
label={t("Your Email")}
|
||||||
mt="md"
|
placeholder="email@example.com"
|
||||||
{...form.getInputProps("password")}
|
variant="filled"
|
||||||
/>
|
mt="md"
|
||||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
{...form.getInputProps("email")}
|
||||||
{t("Setup workspace")}
|
/>
|
||||||
</Button>
|
|
||||||
</form>
|
<PasswordInput
|
||||||
</Box>
|
label={t("Password")}
|
||||||
</Container>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,10 +19,15 @@ import {
|
|||||||
} from "@/features/auth/types/auth.types";
|
} from "@/features/auth/types/auth.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
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 APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { isCloud } from "@/lib/config.ts";
|
||||||
|
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -67,9 +72,21 @@ export default function useAuth() {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await setupWorkspace(data);
|
if (isCloud()) {
|
||||||
setIsLoading(false);
|
const res = await createWorkspace(data);
|
||||||
navigate(APP_ROUTE.HOME);
|
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) {
|
} catch (err) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user