mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 09:22:05 +10:00
Compare commits
170 Commits
v0.8.0
...
feat/new-i
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ebf5c6319 | |||
| 7d7eaf07d6 | |||
| d8f6a32bb2 | |||
| d706959901 | |||
| 41389a4228 | |||
| cbaf3394c0 | |||
| 097e30e992 | |||
| ce1503af85 | |||
| 60e0e517e3 | |||
| d80ce838b5 | |||
| 9ae81b9817 | |||
| 95f24d9ba5 | |||
| 8143452a21 | |||
| 003b8f5515 | |||
| 36a573fce9 | |||
| af54e1827d | |||
| 3bbf7c4475 | |||
| ca23c9a4f2 | |||
| 065f888c32 | |||
| ec533934de | |||
| ef2a6be59b | |||
| 915b31b8bd | |||
| e2b8899569 | |||
| f6e3230eec | |||
| 625bdc7024 | |||
| 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 | |||
| 7d034e8a8b | |||
| 81b6c7ef69 | |||
| 89f6b0a8c2 | |||
| ad1571b902 | |||
| 4b9ab4f63c | |||
| 08829ea721 | |||
| 6c502b4749 | |||
| 6b41538b60 | |||
| 496f5d7384 | |||
| 32c7a16d06 | |||
| 64ecef09bc | |||
| 3e5cb92621 | |||
| fd5ad2f576 |
@ -41,4 +41,6 @@ SMTP_IGNORETLS=false
|
||||
POSTMARK_TOKEN=
|
||||
|
||||
# for custom drawio server
|
||||
DRAWIO_URL=
|
||||
DRAWIO_URL=
|
||||
|
||||
DISABLE_TELEMETRY=false
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,6 @@
|
||||
.env
|
||||
.env.dev
|
||||
.env.prod
|
||||
data
|
||||
# compiled output
|
||||
/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"
|
||||
|
||||
FROM base AS builder
|
||||
@ -7,7 +7,7 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN npm install -g pnpm@10.4.0
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
|
||||
@ -33,7 +33,7 @@ COPY --from=builder /app/pnpm*.yaml /app/
|
||||
# Copy patches
|
||||
COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN npm install -g pnpm
|
||||
RUN npm install -g pnpm@10.4.0
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
|
||||
42
README.md
42
README.md
@ -4,18 +4,18 @@
|
||||
Open-source collaborative wiki and documentation software.
|
||||
<br />
|
||||
<a href="https://docmost.com"><strong>Website</strong></a> |
|
||||
<a href="https://docmost.com/docs"><strong>Documentation</strong></a>
|
||||
<a href="https://docmost.com/docs"><strong>Documentation</strong></a> |
|
||||
<a href="https://twitter.com/DocmostHQ"><strong>Twitter / X</strong></a>
|
||||
</p>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
> [!NOTE]
|
||||
> Docmost is currently in **beta**. We value your feedback as we progress towards a stable release.
|
||||
|
||||
## Getting started
|
||||
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs).
|
||||
|
||||
To get started with Docmost, please refer to our [documentation](https://docmost.com/docs) or try our [cloud version](https://docmost.com/pricing) .
|
||||
|
||||
## Features
|
||||
|
||||
- Real-time collaboration
|
||||
- Diagrams (Draw.io, Excalidraw and Mermaid)
|
||||
- Spaces
|
||||
@ -24,13 +24,39 @@ To get started with Docmost, please refer to our [documentation](https://docmost
|
||||
- Comments
|
||||
- Page history
|
||||
- Search
|
||||
- File attachment
|
||||
- File attachments
|
||||
- Embeds (Airtable, Loom, Miro and more)
|
||||
- Translations (10+ languages)
|
||||
|
||||
### Screenshots
|
||||
|
||||
#### Screenshots
|
||||
<p align="center">
|
||||
<img alt="home" src="https://docmost.com/screenshots/home.png" width="70%">
|
||||
<img alt="editor" src="https://docmost.com/screenshots/editor.png" width="70%">
|
||||
</p>
|
||||
|
||||
### Contributing
|
||||
### License
|
||||
Docmost core is licensed under the open-source AGPL 3.0 license.
|
||||
Enterprise features are available under an enterprise license (Enterprise Edition).
|
||||
|
||||
All files in the following directories are licensed under the Docmost Enterprise license defined in `packages/ee/License`.
|
||||
- apps/server/src/ee
|
||||
- apps/client/src/ee
|
||||
- packages/ee
|
||||
|
||||
### Contributing
|
||||
|
||||
See the [development documentation](https://docmost.com/docs/self-hosting/development)
|
||||
|
||||
## Thanks
|
||||
Special thanks to;
|
||||
|
||||
<img width="100" alt="Crowdin" src="https://github.com/user-attachments/assets/a6c3d352-e41b-448d-b6cd-3fbca3109f07" />
|
||||
|
||||
[Crowdin](https://crowdin.com/) for providing access to their localization platform.
|
||||
|
||||
|
||||
<img width="48" alt="Algolia-mark-square-white" src="https://github.com/user-attachments/assets/6ccad04a-9589-4965-b6a1-d5cb1f4f9e94" />
|
||||
|
||||
[Algolia](https://www.algolia.com/) for providing full-text search to the docs.
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Docmost</title>
|
||||
<!--meta-tags-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"version": "0.20.4",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -16,26 +16,30 @@
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@mantine/core": "^7.14.2",
|
||||
"@mantine/form": "^7.14.2",
|
||||
"@mantine/hooks": "^7.14.2",
|
||||
"@mantine/modals": "^7.14.2",
|
||||
"@mantine/notifications": "^7.14.2",
|
||||
"@mantine/spotlight": "^7.14.2",
|
||||
"@mantine/core": "^7.17.0",
|
||||
"@mantine/form": "^7.17.0",
|
||||
"@mantine/hooks": "^7.17.0",
|
||||
"@mantine/modals": "^7.17.0",
|
||||
"@mantine/notifications": "^7.17.0",
|
||||
"@mantine/spotlight": "^7.17.0",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.4",
|
||||
"axios": "^1.7.9",
|
||||
"@tiptap/extension-character-count": "^2.11.5",
|
||||
"axios": "^1.8.4",
|
||||
"clsx": "^2.1.1",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlightjs-sap-abap": "^0.3.0",
|
||||
"i18next": "^23.14.0",
|
||||
"i18next-http-backend": "^2.6.1",
|
||||
"jotai": "^2.10.3",
|
||||
"jotai": "^2.12.1",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"katex": "0.16.21",
|
||||
"lowlight": "^3.2.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"mitt": "^3.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-arborist": "3.4.0",
|
||||
"react-clear-modal": "^2.0.11",
|
||||
@ -45,9 +49,10 @@
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-router-dom": "^7.0.1",
|
||||
"semver": "^7.7.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -59,7 +64,7 @@
|
||||
"@types/node": "22.10.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
@ -72,6 +77,6 @@
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^6.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "Rolle für alle eingeladenen Mitglieder auswählen",
|
||||
"Select theme": "Design auswählen",
|
||||
"Send invitation": "Einladung senden",
|
||||
"Invitation sent": "Einladung gesendet",
|
||||
"Settings": "Einstellungen",
|
||||
"Setup workspace": "Arbeitsbereich einrichten",
|
||||
"Sign In": "Anmelden",
|
||||
@ -244,6 +245,7 @@
|
||||
"Align left": "Links ausrichten",
|
||||
"Align right": "Rechts ausrichten",
|
||||
"Align center": "Zentrieren",
|
||||
"Justify": "Blocksatz",
|
||||
"Merge cells": "Zellen zusammenführen",
|
||||
"Split cell": "Zelle teilen",
|
||||
"Delete column": "Spalte löschen",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
|
||||
"Names do not match": "Namen stimmen nicht überein",
|
||||
"Today, {{time}}": "Heute, {{time}}",
|
||||
"Yesterday, {{time}}": "Gestern, {{time}}"
|
||||
"Yesterday, {{time}}": "Gestern, {{time}}",
|
||||
"Space created successfully": "Der Bereich wurde erfolgreich erstellt",
|
||||
"Space updated successfully": "Der Bereich wurde erfolgreich aktualisiert",
|
||||
"Space deleted successfully": "Der Bereich wurde erfolgreich gelöscht",
|
||||
"Members added successfully": "Mitglieder erfolgreich hinzugefügt",
|
||||
"Member removed successfully": "Mitglied erfolgreich entfernt",
|
||||
"Member role updated successfully": "Mitgliederrolle erfolgreich aktualisiert",
|
||||
"Created by: <b>{{creatorName}}</b>": "Erstellt von: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Erstellt am: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Bearbeitet von {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Wortanzahl: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Zeichenzahl: {{characterCount}}",
|
||||
"New update": "Neues Update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} ist verfügbar",
|
||||
"Delete member": "Mitglied löschen",
|
||||
"Member deleted successfully": "Mitglied erfolgreich gelöscht",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sind Sie sicher, dass Sie dieses Arbeitsbereichsmitglied löschen möchten? Diese Aktion ist unwiderruflich.",
|
||||
"Move": "Verschieben",
|
||||
"Move page": "Seite verschieben",
|
||||
"Move page to a different space.": "Seite in einen anderen Bereich verschieben.",
|
||||
"Real-time editor connection lost. Retrying...": "Echtzeit-Editor-Verbindung verloren. Wiederholen...",
|
||||
"Table of contents": "Inhaltsverzeichnis",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Fügen Sie Überschriften (H1, H2, H3) hinzu, um ein Inhaltsverzeichnis zu erstellen.",
|
||||
"Share": "Teilen",
|
||||
"Public sharing": "Öffentliches Teilen",
|
||||
"Shared by": "Geteilt von",
|
||||
"Shared at": "Geteilt am",
|
||||
"Inherits public sharing from": "Erbt das öffentliche Teilen von",
|
||||
"Share to web": "Im Web teilen",
|
||||
"Shared to web": "Im Web geteilt",
|
||||
"Anyone with the link can view this page": "Jeder mit dem Link kann diese Seite ansehen",
|
||||
"Make this page publicly accessible": "Diese Seite öffentlich zugänglich machen",
|
||||
"Include sub-pages": "Unterseiten einbeziehen",
|
||||
"Make sub-pages public too": "Unterseiten auch öffentlich machen",
|
||||
"Allow search engines to index page": "Suchmaschinen erlauben, die Seite zu indexieren",
|
||||
"Open page": "Seite öffnen",
|
||||
"Page": "Seite",
|
||||
"Delete public share link": "Öffentlichen Freigabelink löschen",
|
||||
"Delete share": "Freigabe löschen",
|
||||
"Are you sure you want to delete this shared link?": "Möchten Sie diesen Freigabelink wirklich löschen?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Öffentlich geteilte Seiten aus Bereichen, in denen Sie Mitglied sind, erscheinen hier",
|
||||
"Share deleted successfully": "Freigabe erfolgreich gelöscht",
|
||||
"Share not found": "Freigabe nicht gefunden",
|
||||
"Failed to share page": "Fehler beim Teilen der Seite"
|
||||
}
|
||||
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "Select role to assign to all invited members",
|
||||
"Select theme": "Select theme",
|
||||
"Send invitation": "Send invitation",
|
||||
"Invitation sent": "Invitation sent",
|
||||
"Settings": "Settings",
|
||||
"Setup workspace": "Setup workspace",
|
||||
"Sign In": "Sign In",
|
||||
@ -339,5 +340,51 @@
|
||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||
"Names do not match": "Names do not match",
|
||||
"Today, {{time}}": "Today, {{time}}",
|
||||
"Yesterday, {{time}}": "Yesterday, {{time}}"
|
||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||
"Space created successfully": "Space created successfully",
|
||||
"Space updated successfully": "Space updated successfully",
|
||||
"Space deleted successfully": "Space deleted successfully",
|
||||
"Members added successfully": "Members added successfully",
|
||||
"Member removed successfully": "Member removed successfully",
|
||||
"Member role updated successfully": "Member role updated successfully",
|
||||
"Created by: <b>{{creatorName}}</b>": "Created by: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Created at: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Edited by {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||
"New update": "New update",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||
"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",
|
||||
"Join the workspace": "Unirse al espacio de trabajo",
|
||||
"Language": "Idioma",
|
||||
"Light": "Ligero",
|
||||
"Light": "Claro",
|
||||
"Link copied": "Enlace copiado",
|
||||
"Login": "Iniciar sesión",
|
||||
"Logout": "Cerrar sesión",
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "Seleccionar rol para asignar a todos los miembros invitados",
|
||||
"Select theme": "Seleccionar tema",
|
||||
"Send invitation": "Enviar invitación",
|
||||
"Invitation sent": "Invitación enviada",
|
||||
"Settings": "Ajustes",
|
||||
"Setup workspace": "Configurar espacio de trabajo",
|
||||
"Sign In": "Iniciar sesión",
|
||||
@ -244,6 +245,7 @@
|
||||
"Align left": "Alinear a la izquierda",
|
||||
"Align right": "Alinear a la derecha",
|
||||
"Align center": "Alinear al centro",
|
||||
"Justify": "Justificar",
|
||||
"Merge cells": "Combinar celdas",
|
||||
"Split cell": "Dividir celda",
|
||||
"Delete column": "Eliminar columna",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
|
||||
"Names do not match": "Los nombres no coinciden",
|
||||
"Today, {{time}}": "Hoy, {{time}}",
|
||||
"Yesterday, {{time}}": "Ayer, {{time}}"
|
||||
"Yesterday, {{time}}": "Ayer, {{time}}",
|
||||
"Space created successfully": "Espacio creado con éxito",
|
||||
"Space updated successfully": "Espacio actualizado con éxito",
|
||||
"Space deleted successfully": "Espacio eliminado con éxito",
|
||||
"Members added successfully": "Miembros añadidos con éxito",
|
||||
"Member removed successfully": "Miembro eliminado con éxito",
|
||||
"Member role updated successfully": "Rol de miembro actualizado con éxito",
|
||||
"Created by: <b>{{creatorName}}</b>": "Creado por: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Creado a: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Conteo de palabras: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Recuento de caracteres: {{characterCount}}",
|
||||
"New update": "Nueva actualización",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} está disponible",
|
||||
"Delete member": "Eliminar miembro",
|
||||
"Member deleted successfully": "Miembro eliminado con éxito",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "¿Está seguro que desea eliminar este miembro del área de trabajo? Esta acción es irreversible.",
|
||||
"Move": "Mover",
|
||||
"Move page": "Mover página",
|
||||
"Move page to a different space.": "Mover página a un espacio diferente.",
|
||||
"Real-time editor connection lost. Retrying...": "Conexión del editor en tiempo real perdida. Reintentando...",
|
||||
"Table of contents": "Índice de contenidos",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Añadir encabezados (H1, H2, H3) para generar un índice de contenidos.",
|
||||
"Share": "Compartir",
|
||||
"Public sharing": "Compartición pública",
|
||||
"Shared by": "Compartido por",
|
||||
"Shared at": "Compartido en",
|
||||
"Inherits public sharing from": "Hereda la compartición pública de",
|
||||
"Share to web": "Compartir en la web",
|
||||
"Shared to web": "Compartido en la web",
|
||||
"Anyone with the link can view this page": "Cualquiera con el enlace puede ver esta página",
|
||||
"Make this page publicly accessible": "Hacer esta página accesible públicamente",
|
||||
"Include sub-pages": "Incluir subpáginas",
|
||||
"Make sub-pages public too": "Hacer públicas también las subpáginas",
|
||||
"Allow search engines to index page": "Permitir a los motores de búsqueda indexar la página",
|
||||
"Open page": "Abrir página",
|
||||
"Page": "Página",
|
||||
"Delete public share link": "Eliminar enlace de compartición pública",
|
||||
"Delete share": "Eliminar compartición",
|
||||
"Are you sure you want to delete this shared link?": "¿Está seguro de que desea eliminar este enlace compartido?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Las páginas compartidas públicamente de los espacios a los que pertenece aparecerán aquí",
|
||||
"Share deleted successfully": "Compartición eliminada con éxito",
|
||||
"Share not found": "Compartición no encontrada",
|
||||
"Failed to share page": "Error al compartir la página"
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
"Can view": "Peut voir",
|
||||
"Can view pages in space but not edit.": "Peut voir les pages dans l'espace mais ne peut pas les modifier.",
|
||||
"Cancel": "Annuler",
|
||||
"Change email": "Changer l'email",
|
||||
"Change email": "Changer le courriel",
|
||||
"Change password": "Changer le mot de passe",
|
||||
"Change photo": "Changer la photo",
|
||||
"Choose a role": "Choisir un rôle",
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "Sélectionner le rôle à attribuer à tous les membres invités",
|
||||
"Select theme": "Sélectionner le thème",
|
||||
"Send invitation": "Envoyer l'invitation",
|
||||
"Invitation sent": "Invitation envoyée",
|
||||
"Settings": "Paramètres",
|
||||
"Setup workspace": "Configurer l'espace de travail",
|
||||
"Sign In": "Se connecter",
|
||||
@ -244,6 +245,7 @@
|
||||
"Align left": "Aligner à gauche",
|
||||
"Align right": "Aligner à droite",
|
||||
"Align center": "Aligner au centre",
|
||||
"Justify": "Justifier",
|
||||
"Merge cells": "Fusionner les cellules",
|
||||
"Split cell": "Diviser la cellule",
|
||||
"Delete column": "Supprimer la colonne",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
|
||||
"Names do not match": "Les noms ne correspondent pas",
|
||||
"Today, {{time}}": "Aujourd'hui, {{time}}",
|
||||
"Yesterday, {{time}}": "Hier, {{time}}"
|
||||
"Yesterday, {{time}}": "Hier, {{time}}",
|
||||
"Space created successfully": "Espace créé avec succès",
|
||||
"Space updated successfully": "Espace mis à jour avec succès",
|
||||
"Space deleted successfully": "Espace supprimé avec succès",
|
||||
"Members added successfully": "Membres ajoutés avec succès",
|
||||
"Member removed successfully": "Membre supprimé avec succès",
|
||||
"Member role updated successfully": "Rôle du membre mis à jour avec succès",
|
||||
"Created by: <b>{{creatorName}}</b>": "Créé par : <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Créé à : {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Modifié par {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Nombre de mots : {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Nombre de caractères : {{characterCount}}",
|
||||
"New update": "Nouvelle mise à jour",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} est disponible",
|
||||
"Delete member": "Supprimer le membre",
|
||||
"Member deleted successfully": "Membre supprimé avec succès",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Êtes-vous sûr de vouloir supprimer ce membre de l'espace de travail? Cette action est irréversible.",
|
||||
"Move": "Déplacer",
|
||||
"Move page": "Déplacer la page",
|
||||
"Move page to a different space.": "Déplacer la page vers un autre espace.",
|
||||
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
|
||||
"Table of contents": "",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
|
||||
"Share": "Partager",
|
||||
"Public sharing": "Partage public",
|
||||
"Shared by": "Partagé par",
|
||||
"Shared at": "Partagé à",
|
||||
"Inherits public sharing from": "Hérite du partage public de",
|
||||
"Share to web": "Partager sur le web",
|
||||
"Shared to web": "Partagé sur le web",
|
||||
"Anyone with the link can view this page": "Toute personne avec le lien peut voir cette page",
|
||||
"Make this page publicly accessible": "Rendre cette page accessible au public",
|
||||
"Include sub-pages": "Inclure les sous-pages",
|
||||
"Make sub-pages public too": "Rendre également les sous-pages publiques",
|
||||
"Allow search engines to index page": "Autoriser les moteurs de recherche à indexer la page",
|
||||
"Open page": "Ouvrir la page",
|
||||
"Page": "Page",
|
||||
"Delete public share link": "Supprimer le lien de partage public",
|
||||
"Delete share": "Supprimer le partage",
|
||||
"Are you sure you want to delete this shared link?": "Êtes-vous sûr de vouloir supprimer ce lien partagé ?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Les pages partagées publiquement des espaces dont vous êtes membre apparaîtront ici",
|
||||
"Share deleted successfully": "Partage supprimé avec succès",
|
||||
"Share not found": "Partage non trouvé",
|
||||
"Failed to share page": "Échec du partage de la page"
|
||||
}
|
||||
|
||||
@ -12,21 +12,21 @@
|
||||
"Are you sure you want to delete this page?": "Sei sicuro di voler eliminare questa pagina?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Sei sicuro di voler rimuovere questo utente dal gruppo? L'utente perderà l'accesso alle risorse accessibili da questo gruppo.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Sei sicuro di voler rimuovere questo utente dallo spazio? L'utente perderà tutti gli accessi a questo spazio.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sei sicuro di voler ripristinare questa versione? Qualsiasi modifica non salvata come versione andrà persa.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Sei sicuro di voler ripristinare questa versione? Qualsiasi modifica non versionata verrà persa.",
|
||||
"Can become members of groups and spaces in workspace": "Può diventare membro di gruppi e spazi nell'area di lavoro",
|
||||
"Can create and edit pages in space.": "Può creare e modificare le pagine nello spazio.",
|
||||
"Can edit": "Può modificare",
|
||||
"Can manage workspace": "Può gestire lo spazio di lavoro",
|
||||
"Can manage workspace": "Può gestire l'area di lavoro",
|
||||
"Can manage workspace but cannot delete it": "Può gestire lo spazio di lavoro ma non può eliminarlo",
|
||||
"Can view": "Può visualizzare",
|
||||
"Can view pages in space but not edit.": "Può visualizzare le pagine nello spazio ma non modificarle.",
|
||||
"Can view pages in space but not edit.": "Può visualizzare le pagine nello spazio ma non può modificarle.",
|
||||
"Cancel": "Annulla",
|
||||
"Change email": "Cambia email",
|
||||
"Change password": "Cambia password",
|
||||
"Change photo": "Cambia foto",
|
||||
"Choose a role": "Scegli un ruolo",
|
||||
"Choose your preferred color scheme.": "Scegli il tuo schema di colori preferito.",
|
||||
"Choose your preferred interface language.": "Scegli la tua lingua preferita per l'interfaccia.",
|
||||
"Choose your preferred color scheme.": "Scegli il tema che preferisci.",
|
||||
"Choose your preferred interface language.": "Scegli la lingua da utilizzare per l'interfaccia.",
|
||||
"Choose your preferred page width.": "Scegli la larghezza della pagina che preferisci.",
|
||||
"Confirm": "Conferma",
|
||||
"Copy link": "Copia link",
|
||||
@ -34,7 +34,7 @@
|
||||
"Create group": "Crea gruppo",
|
||||
"Create page": "Crea pagina",
|
||||
"Create space": "Crea spazio",
|
||||
"Create workspace": "Crea spazio di lavoro",
|
||||
"Create workspace": "Crea area di lavoro",
|
||||
"Current password": "Password attuale",
|
||||
"Dark": "Scuro",
|
||||
"Date": "Data",
|
||||
@ -43,21 +43,21 @@
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Sei sicuro di voler eliminare questa pagina? Verranno cancellate anche le sue sottopagine e la cronologia. Questa azione è irreversibile.",
|
||||
"Description": "Descrizione",
|
||||
"Details": "Dettagli",
|
||||
"e.g ACME": "ad es. ACME",
|
||||
"e.g ACME": "es. ACME",
|
||||
"e.g ACME Inc": "es. ACME Inc",
|
||||
"e.g Developers": "es. Sviluppatori",
|
||||
"e.g Group for developers": "es. Gruppo per gli sviluppatori",
|
||||
"e.g product": "ad esempio prodotto",
|
||||
"e.g product": "es. prodotto",
|
||||
"e.g Product Team": "es. Team di Prodotto",
|
||||
"e.g Sales": "ad es. Vendite",
|
||||
"e.g Space for product team": "ad es. Spazio per il team di prodotto",
|
||||
"e.g Space for sales team to collaborate": "ad es. Spazio per il team di vendita per collaborare",
|
||||
"e.g Sales": "es. Vendite",
|
||||
"e.g Space for product team": "es. Spazio per il team di prodotto",
|
||||
"e.g Space for sales team to collaborate": "es. Spazio per la collaborazione del team di vendita",
|
||||
"Edit": "Modifica",
|
||||
"Edit group": "Modifica gruppo",
|
||||
"Email": "Email",
|
||||
"Enter a strong password": "Inserisci una password sicura",
|
||||
"Enter valid email addresses separated by comma or space max_50": "Inserisci indirizzi email validi separati da virgola o spazio [max: 50]",
|
||||
"enter valid emails addresses": "inserisci indirizzi email validi",
|
||||
"Enter valid email addresses separated by comma or space max_50": "Inserisci degli indirizzi email validi separati da virgola o spazio [max: 50]",
|
||||
"enter valid emails addresses": "inserisci degli indirizzi email validi",
|
||||
"Enter your current password": "Inserisci la tua password attuale",
|
||||
"enter your full name": "inserisci il tuo nome completo",
|
||||
"Enter your new password": "Inserisci la tua nuova password",
|
||||
@ -66,33 +66,33 @@
|
||||
"Error fetching page data.": "Si è verificato un errore durante il recupero dei dati della pagina.",
|
||||
"Error loading page history.": "Si è verificato un errore durante il caricamento della cronologia della pagina.",
|
||||
"Export": "Esporta",
|
||||
"Failed to create page": "Impossibile creare pagina",
|
||||
"Failed to create page": "Impossibile creare la pagina",
|
||||
"Failed to delete page": "Impossibile eliminare la pagina",
|
||||
"Failed to fetch recent pages": "Impossibile recuperare le pagine recenti",
|
||||
"Failed to import pages": "Impossibile importare le pagine",
|
||||
"Failed to load page. An error occurred.": "Il caricamento della pagina è fallito. Si è verificato un errore.",
|
||||
"Failed to update data": "Impossibile aggiornare i dati",
|
||||
"Full access": "Accesso completo",
|
||||
"Full page width": "Larghezza pagina intera",
|
||||
"Full page width": "Pagina a larghezza intera",
|
||||
"Full width": "Larghezza intera",
|
||||
"General": "Generale",
|
||||
"Group": "Gruppo",
|
||||
"Group description": "Descrizione del gruppo",
|
||||
"Group name": "Nome del gruppo",
|
||||
"Groups": "Gruppi",
|
||||
"Has full access to space settings and pages.": "Ha pieno accesso alle impostazioni e alle pagine dello spazio.",
|
||||
"Has full access to space settings and pages.": "Ha pieno accesso alle impostazioni dello spazio e alle sue pagine.",
|
||||
"Home": "Casa",
|
||||
"Import pages": "Importa pagine",
|
||||
"Import pages & space settings": "Importa pagine e impostazioni dello spazio",
|
||||
"Importing pages": "Importazione pagine",
|
||||
"invalid invitation link": "link di invito non valido",
|
||||
"Invitation signup": "Iscrizione invito",
|
||||
"Invite by email": "Invita via email",
|
||||
"Invite by email": "Invita tramite email",
|
||||
"Invite members": "Invita membri",
|
||||
"Invite new members": "Invita nuovi membri",
|
||||
"Invited members who are yet to accept their invitation will appear here.": "I membri invitati che non hanno ancora accettato il loro invito appariranno qui.",
|
||||
"Invited members will be granted access to spaces the groups can access": "I membri invitati avranno accesso agli spazi a cui i gruppi possono accedere",
|
||||
"Join the workspace": "Unisciti allo spazio di lavoro",
|
||||
"Join the workspace": "Unisciti all'area di lavoro",
|
||||
"Language": "Lingua",
|
||||
"Light": "Chiaro",
|
||||
"Link copied": "Link copiato",
|
||||
@ -105,15 +105,15 @@
|
||||
"members": "membri",
|
||||
"Members": "Membri",
|
||||
"My preferences": "Le mie preferenze",
|
||||
"My Profile": "Il mio profilo",
|
||||
"My Profile": "Il Mio Profilo",
|
||||
"My profile": "Il mio profilo",
|
||||
"Name": "Nome",
|
||||
"New email": "Nuova email",
|
||||
"New page": "Nuova pagina",
|
||||
"New password": "Nuova password",
|
||||
"No group found": "Nessun gruppo trovato",
|
||||
"No page history saved yet.": "Nessuna cronologia della pagina salvata.",
|
||||
"No pages yet": "Nessuna pagina ancora",
|
||||
"No page history saved yet.": "La pagina non ha una cronologia per ora.",
|
||||
"No pages yet": "Nessuna pagina per ora",
|
||||
"No results found...": "Nessun risultato trovato...",
|
||||
"No user found": "Nessun utente trovato",
|
||||
"Overview": "Panoramica",
|
||||
@ -139,49 +139,50 @@
|
||||
"Role": "Ruolo",
|
||||
"Save": "Salva",
|
||||
"Search": "Cerca",
|
||||
"Search for groups": "Cerca gruppi",
|
||||
"Search for groups": "Cerca un gruppo",
|
||||
"Search for users": "Cerca un utente",
|
||||
"Search for users and groups": "Cerca utenti e gruppi",
|
||||
"Search for users and groups": "Cerca un utente o un gruppo",
|
||||
"Search...": "Cerca...",
|
||||
"Select language": "Seleziona lingua",
|
||||
"Select role": "Seleziona ruolo",
|
||||
"Select language": "Seleziona una lingua",
|
||||
"Select role": "Seleziona un ruolo",
|
||||
"Select role to assign to all invited members": "Seleziona il ruolo da assegnare a tutti i membri invitati",
|
||||
"Select theme": "Seleziona tema",
|
||||
"Select theme": "Seleziona un tema",
|
||||
"Send invitation": "Invia invito",
|
||||
"Invitation sent": "Invito inviato",
|
||||
"Settings": "Impostazioni",
|
||||
"Setup workspace": "Imposta spazio di lavoro",
|
||||
"Setup workspace": "Configura l'area di lavoro",
|
||||
"Sign In": "Accedi",
|
||||
"Sign Up": "Registrati",
|
||||
"Slug": "Identificatore",
|
||||
"Slug": "Slug",
|
||||
"Space": "Spazio",
|
||||
"Space description": "Descrizione dello spazio",
|
||||
"Space menu": "Menu spazio",
|
||||
"Space name": "Nome dello spazio",
|
||||
"Space settings": "Impostazioni dello spazio",
|
||||
"Space slug": "Lumaca spaziale",
|
||||
"Space slug": "Slug dello spazio",
|
||||
"Spaces": "Spazi",
|
||||
"Spaces you belong to": "Spazi a cui appartieni",
|
||||
"No space found": "Nessuno spazio trovato",
|
||||
"Search for spaces": "Cerca spazi",
|
||||
"Search for spaces": "Cerca uno spazio",
|
||||
"Start typing to search...": "Inizia a digitare per cercare...",
|
||||
"Status": "Stato",
|
||||
"Successfully imported": "Importazione riuscita",
|
||||
"Successfully imported": "Importato con successo",
|
||||
"Successfully restored": "Ripristinato con successo",
|
||||
"System settings": "Impostazioni di sistema",
|
||||
"Theme": "Tema",
|
||||
"To change your email, you have to enter your password and new email.": "Per cambiare la tua email, devi inserire la tua password e la nuova email.",
|
||||
"Toggle full page width": "Attiva/disattiva larghezza pagina intera",
|
||||
"Toggle full page width": "Attiva/disattiva pagina a larghezza intera",
|
||||
"Unable to import pages. Please try again.": "Impossibile importare le pagine. Riprova.",
|
||||
"untitled": "senza titolo",
|
||||
"Untitled": "Senza titolo",
|
||||
"Updated successfully": "Aggiornato con successo",
|
||||
"User": "Utente",
|
||||
"Workspace": "Spazio di lavoro",
|
||||
"Workspace Name": "Nome dello spazio di lavoro",
|
||||
"Workspace settings": "Impostazioni dello spazio di lavoro",
|
||||
"You can change your password here.": "Puoi cambiare la tua password qui.",
|
||||
"Workspace": "Area di lavoro",
|
||||
"Workspace Name": "Nome dell'area di lavoro",
|
||||
"Workspace settings": "Impostazioni dell'area di lavoro",
|
||||
"You can change your password here.": "Qui puoi cambiare la tua password.",
|
||||
"Your Email": "La tua email",
|
||||
"Your import is complete.": "Il tuo importazione è completata.",
|
||||
"Your import is complete.": "La tua importazione è completata.",
|
||||
"Your name": "Il tuo nome",
|
||||
"Your Name": "Il Tuo Nome",
|
||||
"Your password": "La tua password",
|
||||
@ -190,18 +191,18 @@
|
||||
"Comments": "Commenti",
|
||||
"404 page not found": "404 pagina non trovata",
|
||||
"Sorry, we can't find the page you are looking for.": "Siamo spiacenti, non riusciamo a trovare la pagina che stai cercando.",
|
||||
"Take me back to homepage": "Portami alla homepage",
|
||||
"Forgot password": "Hai dimenticato la password",
|
||||
"Take me back to homepage": "Torna all'homepage",
|
||||
"Forgot password": "Password dimenticata",
|
||||
"Forgot your password?": "Hai dimenticato la password?",
|
||||
"A password reset link has been sent to your email. Please check your inbox.": "Un link per il reset della password è stato inviato al tuo indirizzo email. Per favore, controlla la tua casella di posta.",
|
||||
"Send reset link": "Invia link di ripristino",
|
||||
"Send reset link": "Invia link per il ripristino della password",
|
||||
"Password reset": "Reimposta password",
|
||||
"Your new password": "La tua nuova password",
|
||||
"Set password": "Imposta password",
|
||||
"Write a comment": "Scrivi un commento",
|
||||
"Reply...": "Rispondi...",
|
||||
"Error loading comments.": "Si è verificato un errore durante il caricamento dei commenti.",
|
||||
"No comments yet.": "Nessun commento ancora.",
|
||||
"No comments yet.": "Nessun commento per ora.",
|
||||
"Edit comment": "Modifica commento",
|
||||
"Delete comment": "Elimina commento",
|
||||
"Are you sure you want to delete this comment?": "Sei sicuro di voler eliminare questo commento?",
|
||||
@ -216,19 +217,19 @@
|
||||
"Revoke invitation": "Revoca invito",
|
||||
"Revoke": "Revoca",
|
||||
"Don't": "Non",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Sei sicuro di voler revocare questo invito? L'utente non potrà unirsi allo spazio di lavoro.",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Sei sicuro di voler revocare questo invito? L'utente non potrà unirsi all'area di lavoro.",
|
||||
"Resend invitation": "Rispedisci invito",
|
||||
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questo workspace.",
|
||||
"Anyone with this link can join this workspace.": "Chiunque con questo link può unirsi a questa area di lavoro.",
|
||||
"Invite link": "Link d'invito",
|
||||
"Copy": "Copia",
|
||||
"Copied": "Copiato",
|
||||
"Select a user": "Seleziona un utente",
|
||||
"Select a group": "Seleziona un gruppo",
|
||||
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati in questo spazio.",
|
||||
"Export all pages and attachments in this space.": "Esporta tutte le pagine e gli allegati di questo spazio.",
|
||||
"Delete space": "Elimina spazio",
|
||||
"Are you sure you want to delete this space?": "Sei sicuro di voler eliminare questo spazio?",
|
||||
"Delete this space with all its pages and data.": "Elimina questo spazio con tutte le sue pagine e i suoi dati.",
|
||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Tutte le pagine, i commenti, gli allegati e i permessi in questo spazio verranno eliminati in modo irreversibile.",
|
||||
"All pages, comments, attachments and permissions in this space will be deleted irreversibly.": "Tutte le pagine, i commenti, gli allegati e i permessi di questo spazio verranno eliminati irreversibilmente.",
|
||||
"Confirm space name": "Conferma nome spazio",
|
||||
"Type the space name <b>{{spaceName}}</b> to confirm your action.": "Digita il nome dello spazio <b>{{spaceName}}</b> per confermare la tua azione.",
|
||||
"Format": "Formato",
|
||||
@ -240,10 +241,11 @@
|
||||
"Export page": "Esporta pagina",
|
||||
"Export space": "Esporta spazio",
|
||||
"Export {{type}}": "Esporta {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Il file supera il limite di allegati di {{limit}}",
|
||||
"File exceeds the {{limit}} attachment limit": "Il file supera il limite per gli allegati di {{limit}}",
|
||||
"Align left": "Allinea a sinistra",
|
||||
"Align right": "Allinea a destra",
|
||||
"Align center": "Allinea al centro",
|
||||
"Justify": "Giustifica",
|
||||
"Merge cells": "Unisci celle",
|
||||
"Split cell": "Dividi cella",
|
||||
"Delete column": "Elimina colonna",
|
||||
@ -259,10 +261,10 @@
|
||||
"Danger": "Pericolo",
|
||||
"Mermaid diagram error:": "Errore nel diagramma di Mermaid:",
|
||||
"Invalid Mermaid diagram": "Diagramma di Mermaid non valido",
|
||||
"Double-click to edit Draw.io diagram": "Doppio clic per modificare il diagramma Draw.io",
|
||||
"Double-click to edit Draw.io diagram": "Fai doppio clic per modificare il diagramma di Draw.io",
|
||||
"Exit": "Esci",
|
||||
"Save & Exit": "Salva ed esci",
|
||||
"Double-click to edit Excalidraw diagram": "Doppio clic per modificare il diagramma Excalidraw",
|
||||
"Double-click to edit Excalidraw diagram": "Fai doppio clic per modificare il diagramma di Excalidraw",
|
||||
"Paste link": "Incolla link",
|
||||
"Edit link": "Modifica link",
|
||||
"Remove link": "Rimuovi link",
|
||||
@ -298,7 +300,7 @@
|
||||
"To-do List": "Lista delle cose da fare",
|
||||
"Bullet List": "Elenco Puntato",
|
||||
"Numbered List": "Elenco Numerato",
|
||||
"Blockquote": "Blocco di citazione",
|
||||
"Blockquote": "Citazione",
|
||||
"Just start typing with plain text.": "Inizia a digitare con testo semplice.",
|
||||
"Track tasks with a to-do list.": "Tieni traccia delle attività con una lista di cose da fare.",
|
||||
"Big section heading.": "Intestazione di una grande sezione.",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
|
||||
"Names do not match": "I nomi non corrispondono",
|
||||
"Today, {{time}}": "Oggi, {{time}}",
|
||||
"Yesterday, {{time}}": "Ieri, {{time}}"
|
||||
"Yesterday, {{time}}": "Ieri, {{time}}",
|
||||
"Space created successfully": "Spazio creato con successo",
|
||||
"Space updated successfully": "Spazio aggiornato con successo",
|
||||
"Space deleted successfully": "Spazio eliminato con successo",
|
||||
"Members added successfully": "Membri aggiunti con successo",
|
||||
"Member removed successfully": "Membro rimosso con successo",
|
||||
"Member role updated successfully": "Ruolo del membro aggiornato con successo",
|
||||
"Created by: <b>{{creatorName}}</b>": "Creato da: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Creato il: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Modificato da {{name}} il {{time}}",
|
||||
"Word count: {{wordCount}}": "Conteggio parole: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Conteggio caratteri: {{characterCount}}",
|
||||
"New update": "Nuovo aggiornamento",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} è disponibile",
|
||||
"Delete member": "Elimina membro",
|
||||
"Member deleted successfully": "Membro eliminato con successo",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Sei sicuro di voler eliminare questo membro del workspace? Questa azione è irreversibile.",
|
||||
"Move": "Sposta",
|
||||
"Move page": "Sposta pagina",
|
||||
"Move page to a different space.": "Sposta la pagina in un altro spazio.",
|
||||
"Real-time editor connection lost. Retrying...": "Connessione all'editor in tempo reale persa. Riprovo...",
|
||||
"Table of contents": "Indice dei contenuti",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Aggiungi intestazioni (H1, H2, H3) per generare un sommario.",
|
||||
"Share": "Condividi",
|
||||
"Public sharing": "Condivisione pubblica",
|
||||
"Shared by": "Condiviso da",
|
||||
"Shared at": "Condiviso il",
|
||||
"Inherits public sharing from": "Eredita la condivisione pubblica da",
|
||||
"Share to web": "Condividi su web",
|
||||
"Shared to web": "Condiviso su web",
|
||||
"Anyone with the link can view this page": "Chiunque abbia il link può visualizzare questa pagina",
|
||||
"Make this page publicly accessible": "Rendi questa pagina accessibile pubblicamente",
|
||||
"Include sub-pages": "Includi sotto-pagine",
|
||||
"Make sub-pages public too": "Rendi pubbliche anche le sotto-pagine",
|
||||
"Allow search engines to index page": "Permetti ai motori di ricerca di indicizzare la pagina",
|
||||
"Open page": "Apri pagina",
|
||||
"Page": "Pagina",
|
||||
"Delete public share link": "Elimina il link di condivisione pubblica",
|
||||
"Delete share": "Elimina condivisione",
|
||||
"Are you sure you want to delete this shared link?": "Sei sicuro di voler eliminare questo link condiviso?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Le pagine condivise pubblicamente dagli spazi di cui sei membro appariranno qui",
|
||||
"Share deleted successfully": "Condivisione eliminata con successo",
|
||||
"Share not found": "Condivisione non trovata",
|
||||
"Failed to share page": "Condivisione della pagina fallita"
|
||||
}
|
||||
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "招待されたすべてのメンバーに割り当てるロールを選択してください",
|
||||
"Select theme": "テーマを選択",
|
||||
"Send invitation": "招待を送る",
|
||||
"Invitation sent": "招待が送信されました",
|
||||
"Settings": "設定",
|
||||
"Setup workspace": "ワークスペースを設定する",
|
||||
"Sign In": "サインイン",
|
||||
@ -244,6 +245,7 @@
|
||||
"Align left": "左揃え",
|
||||
"Align right": "右揃え",
|
||||
"Align center": "中央揃え",
|
||||
"Justify": "両端揃え",
|
||||
"Merge cells": "セルを結合",
|
||||
"Split cell": "セルを分割",
|
||||
"Delete column": "列を削除",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
|
||||
"Names do not match": "名前が一致しません",
|
||||
"Today, {{time}}": "今日、{{time}}",
|
||||
"Yesterday, {{time}}": "昨日、{{time}}"
|
||||
"Yesterday, {{time}}": "昨日、{{time}}",
|
||||
"Space created successfully": "スペースを作成しました",
|
||||
"Space updated successfully": "スペースを更新しました",
|
||||
"Space deleted successfully": "スペースが削除されました",
|
||||
"Members added successfully": "メンバーを追加しました",
|
||||
"Member removed successfully": "メンバーが削除されました",
|
||||
"Member role updated successfully": "メンバーのロールを更新しました",
|
||||
"Created by: <b>{{creatorName}}</b>": "作成者: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "が作成しました:{{time}}",
|
||||
"Edited by {{name}} {{time}}": "最終編集: {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "ワード数: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "文字数: {{characterCount}}",
|
||||
"New update": "新規更新",
|
||||
"{{latestVersion}} is available": "{{latestVersion}}は利用可能です",
|
||||
"Delete member": "メンバーを削除する",
|
||||
"Member deleted successfully": "メンバーが削除されました",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "ワークスペースメンバーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"Move": "移動",
|
||||
"Move page": "ページを移動",
|
||||
"Move page to a different space.": "ページを別のスペースに移動します。",
|
||||
"Real-time editor connection lost. Retrying...": "リアルタイムエディターの接続が失われました。再試行しています…",
|
||||
"Table of contents": "目次",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "見出し(H1、H2、H3)を追加して目次を生成します。",
|
||||
"Share": "共有",
|
||||
"Public sharing": "公開共有",
|
||||
"Shared by": "共有者",
|
||||
"Shared at": "共有日時",
|
||||
"Inherits public sharing from": "から公開共有を継承する",
|
||||
"Share to web": "ウェブで共有",
|
||||
"Shared to web": "ウェブに共有済み",
|
||||
"Anyone with the link can view this page": "リンクを持っている人はこのページを閲覧できます",
|
||||
"Make this page publicly accessible": "このページを公開します",
|
||||
"Include sub-pages": "サブページを含む",
|
||||
"Make sub-pages public too": "サブページも公開する",
|
||||
"Allow search engines to index page": "検索エンジンにページのインデックス作成を許可する",
|
||||
"Open page": "ページを開く",
|
||||
"Page": "ページ",
|
||||
"Delete public share link": "公開リンクを削除",
|
||||
"Delete share": "共有を削除",
|
||||
"Are you sure you want to delete this shared link?": "この共有リンクを削除してもよろしいですか?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "メンバーであるスペースからの公開ページがここに表示されます",
|
||||
"Share deleted successfully": "共有が正常に削除されました",
|
||||
"Share not found": "共有が見つかりません",
|
||||
"Failed to share page": "ページの共有に失敗しました"
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@
|
||||
"Home": "홈",
|
||||
"Import pages": "페이지 가져오기",
|
||||
"Import pages & space settings": "페이지 및 Space 설정 가져오기",
|
||||
"Importing pages": "페이지 가져오기 중",
|
||||
"Importing pages": "페이지 가져오는 중",
|
||||
"invalid invitation link": "유효하지 않은 초대 링크",
|
||||
"Invitation signup": "초대 가입",
|
||||
"Invite by email": "이메일로 초대",
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "초대된 모든 사용자에게 할당할 역할 선택",
|
||||
"Select theme": "배경 선택",
|
||||
"Send invitation": "초대 보내기",
|
||||
"Invitation sent": "초대 발송 완료",
|
||||
"Settings": "설정",
|
||||
"Setup workspace": "Workspace 설정",
|
||||
"Sign In": "로그인",
|
||||
@ -218,7 +219,7 @@
|
||||
"Don't": "하지 않음",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "이 초대를 취소하시겠습니까? 사용자가 Workspace에 참여할 수 없게 됩니다.",
|
||||
"Resend invitation": "초대 재전송",
|
||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사람이 Workspace에 참여할 수 있습니다.",
|
||||
"Anyone with this link can join this workspace.": "이 링크를 가진 모든 사용자가 이 Workspace에 참여할 수 있습니다.",
|
||||
"Invite link": "초대 링크",
|
||||
"Copy": "복사",
|
||||
"Copied": "복사됨",
|
||||
@ -244,6 +245,7 @@
|
||||
"Align left": "왼쪽 정렬",
|
||||
"Align right": "오른쪽 정렬",
|
||||
"Align center": "가운데 정렬",
|
||||
"Justify": "정렬",
|
||||
"Merge cells": "셀 병합",
|
||||
"Split cell": "셀 분할",
|
||||
"Delete column": "열 삭제",
|
||||
@ -255,7 +257,7 @@
|
||||
"Delete table": "테이블 삭제",
|
||||
"Info": "정보",
|
||||
"Success": "완료",
|
||||
"Warning": "경고",
|
||||
"Warning": "주의",
|
||||
"Danger": "위험",
|
||||
"Mermaid diagram error:": "Mermaid diagram 오류:",
|
||||
"Invalid Mermaid diagram": "잘못된 Mermaid diagram",
|
||||
@ -264,7 +266,7 @@
|
||||
"Save & Exit": "저장 후 나가기",
|
||||
"Double-click to edit Excalidraw diagram": "Excalidraw diagram을 편집하려면 더블 클릭하세요",
|
||||
"Paste link": "링크 붙여넣기",
|
||||
"Edit link": "링크 편집",
|
||||
"Edit link": "링크 수정",
|
||||
"Remove link": "링크 제거",
|
||||
"Add link": "링크 추가",
|
||||
"Please enter a valid url": "유효한 URL을 입력하세요",
|
||||
@ -296,11 +298,11 @@
|
||||
"Heading 2": "제목 2",
|
||||
"Heading 3": "제목 3",
|
||||
"To-do List": "할 일 목록",
|
||||
"Bullet List": "글머리 기호 목록",
|
||||
"Numbered List": "번호 매기기 목록",
|
||||
"Bullet List": "글머리 표",
|
||||
"Numbered List": "문단 번호",
|
||||
"Blockquote": "인용구",
|
||||
"Just start typing with plain text.": "일반 텍스트로 입력을 시작하세요.",
|
||||
"Track tasks with a to-do list.": "할 일 목록으로 작업을 추적하세요.",
|
||||
"Track tasks with a to-do list.": "할 일 목록으로 작업을 정리하세요.",
|
||||
"Big section heading.": "대제목.",
|
||||
"Medium section heading.": "중제목.",
|
||||
"Small section heading.": "소제목.",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
|
||||
"Names do not match": "이름이 일치하지 않습니다",
|
||||
"Today, {{time}}": "오늘, {{time}}",
|
||||
"Yesterday, {{time}}": "어제, {{time}}"
|
||||
"Yesterday, {{time}}": "어제, {{time}}",
|
||||
"Space created successfully": "공간 생성 완료",
|
||||
"Space updated successfully": "공간이 성공적으로 업데이트되었습니다",
|
||||
"Space deleted successfully": "스페이스 삭제 완료",
|
||||
"Members added successfully": "회원 추가 완료",
|
||||
"Member removed successfully": "멤버가 성공적으로 제거되었습니다",
|
||||
"Member role updated successfully": "회원 역할이 성공적으로 업데이트되었습니다",
|
||||
"Created by: <b>{{creatorName}}</b>": "작성자: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "생성 날짜: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "{{name}}님이 편집함 {{time}}",
|
||||
"Word count: {{wordCount}}": "단어 수: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "문자 수: {{characterCount}}",
|
||||
"New update": "새로운 업데이트",
|
||||
"{{latestVersion}} is available": "{{latestVersion}}이 사용 가능합니다",
|
||||
"Delete member": "회원 삭제",
|
||||
"Member deleted successfully": "멤버가 성공적으로 제거되었습니다",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "이 워크스페이스 멤버를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"Move": "이동",
|
||||
"Move page": "페이지 이동",
|
||||
"Move page to a different space.": "페이지를 다른 공간으로 이동합니다.",
|
||||
"Real-time editor connection lost. Retrying...": "실시간 편집기 연결이 끊어졌습니다. 재시도 중...",
|
||||
"Table of contents": "목차",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "목차를 생성하려면 제목 (H1, H2, H3)을 추가하세요.",
|
||||
"Share": "공유",
|
||||
"Public sharing": "공개 공유",
|
||||
"Shared by": "공유자",
|
||||
"Shared at": "공유 시간",
|
||||
"Inherits public sharing from": "로부터 공개 공유를 상속함",
|
||||
"Share to web": "웹에 공유",
|
||||
"Shared to web": "웹에 공유됨",
|
||||
"Anyone with the link can view this page": "링크가 있는 사람은 이 페이지를 볼 수 있습니다",
|
||||
"Make this page publicly accessible": "이 페이지를 공개적으로 접근 가능하게 만들기",
|
||||
"Include sub-pages": "하위 페이지 포함",
|
||||
"Make sub-pages public too": "하위 페이지도 공개로 설정",
|
||||
"Allow search engines to index page": "검색 엔진이 페이지를 색인할 수 있도록 허용",
|
||||
"Open page": "페이지 열기",
|
||||
"Page": "페이지",
|
||||
"Delete public share link": "공유 링크 삭제",
|
||||
"Delete share": "공유 삭제",
|
||||
"Are you sure you want to delete this shared link?": "이 공유 링크를 삭제하시겠습니까?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "회원인 공간의 공개 공유된 페이지가 여기에 표시됩니다",
|
||||
"Share deleted successfully": "공유가 성공적으로 삭제되었습니다",
|
||||
"Share not found": "공유를 찾을 수 없습니다",
|
||||
"Failed to share page": "페이지 공유에 실패했습니다"
|
||||
}
|
||||
|
||||
387
apps/client/public/locales/nl-NL/translation.json
Normal file
387
apps/client/public/locales/nl-NL/translation.json
Normal file
@ -0,0 +1,387 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "Selecione a função para atribuir a todos os membros convidados",
|
||||
"Select theme": "Selecionar tema",
|
||||
"Send invitation": "Enviar convite",
|
||||
"Invitation sent": "Convite enviado",
|
||||
"Settings": "Configurações",
|
||||
"Setup workspace": "Configurar workspace",
|
||||
"Sign In": "Entrar",
|
||||
@ -244,6 +245,7 @@
|
||||
"Align left": "Alinhar à esquerda",
|
||||
"Align right": "Alinhar à direita",
|
||||
"Align center": "Alinhar ao centro",
|
||||
"Justify": "Justificar",
|
||||
"Merge cells": "Mesclar células",
|
||||
"Split cell": "Dividir célula",
|
||||
"Delete column": "Excluir coluna",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
|
||||
"Names do not match": "Os nomes não coincidem",
|
||||
"Today, {{time}}": "Hoje, {{time}}",
|
||||
"Yesterday, {{time}}": "Ontem, {{time}}"
|
||||
"Yesterday, {{time}}": "Ontem, {{time}}",
|
||||
"Space created successfully": "Espaço criado com sucesso",
|
||||
"Space updated successfully": "Espaço atualizado com sucesso",
|
||||
"Space deleted successfully": "Espaço excluído com sucesso",
|
||||
"Members added successfully": "Membros adicionados com sucesso",
|
||||
"Member removed successfully": "Membro removido com sucesso",
|
||||
"Member role updated successfully": "Função do membro atualizada com sucesso",
|
||||
"Created by: <b>{{creatorName}}</b>": "Criado por: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Criado em: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Editado por {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Contagem de palavras: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Contagem de caracteres: {{characterCount}}",
|
||||
"New update": "Nova atualização",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} está disponível",
|
||||
"Delete member": "Excluir membro",
|
||||
"Member deleted successfully": "Membro removido com sucesso",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Você tem certeza que deseja deletar este membro do workspace? Esta ação é irreversível.",
|
||||
"Move": "Mover",
|
||||
"Move page": "Mover página",
|
||||
"Move page to a different space.": "Mover página para um espaço diferente.",
|
||||
"Real-time editor connection lost. Retrying...": "Conexão do editor em tempo real perdida. Tentando novamente...",
|
||||
"Table of contents": "Tabela de conteúdos",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Adicionar títulos (H1, H2, H3) para gerar uma tabela de conteúdo.",
|
||||
"Share": "Compartilhar",
|
||||
"Public sharing": "Compartilhamento público",
|
||||
"Shared by": "Compartilhado por",
|
||||
"Shared at": "Compartilhado em",
|
||||
"Inherits public sharing from": "Herdado do compartilhamento público de",
|
||||
"Share to web": "Compartilhar na web",
|
||||
"Shared to web": "Compartilhado na web",
|
||||
"Anyone with the link can view this page": "Qualquer um com o link pode ver esta página",
|
||||
"Make this page publicly accessible": "Tornar esta página publicamente acessível",
|
||||
"Include sub-pages": "Incluir sub-páginas",
|
||||
"Make sub-pages public too": "Tornar as sub-páginas públicas também",
|
||||
"Allow search engines to index page": "Permitir que mecanismos de busca indexem a página",
|
||||
"Open page": "Abrir página",
|
||||
"Page": "Página",
|
||||
"Delete public share link": "Excluir o link público compartilhado",
|
||||
"Delete share": "Excluir compartilhamento",
|
||||
"Are you sure you want to delete this shared link?": "Tem certeza de que deseja excluir este link compartilhado?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Páginas compartilhadas publicamente de espaços que você é membro aparecerão aqui",
|
||||
"Share deleted successfully": "Compartilhamento excluído com sucesso",
|
||||
"Share not found": "Compartilhamento não encontrado",
|
||||
"Failed to share page": "Falha ao compartilhar página"
|
||||
}
|
||||
|
||||
@ -13,11 +13,11 @@
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым у этой группы есть доступ.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочем пространстве",
|
||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
||||
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||
"Can edit": "Может изменять",
|
||||
"Can manage workspace": "Может управлять рабочим пространством",
|
||||
"Can manage workspace but cannot delete it": "Может управлять рабочим пространством, но не может его удалить",
|
||||
"Can manage workspace": "Может управлять рабочей областью",
|
||||
"Can manage workspace but cannot delete it": "Может управлять рабочей областью, но не может ее удалить",
|
||||
"Can view": "Может просматривать",
|
||||
"Can view pages in space but not edit.": "Может просматривать страницы в пространстве, но не может их редактировать.",
|
||||
"Cancel": "Отменить",
|
||||
@ -34,7 +34,7 @@
|
||||
"Create group": "Создать группу",
|
||||
"Create page": "Создать страницу",
|
||||
"Create space": "Создать пространство",
|
||||
"Create workspace": "Создать рабочее пространство",
|
||||
"Create workspace": "Создать рабочую область",
|
||||
"Current password": "Текущий пароль",
|
||||
"Dark": "Темная",
|
||||
"Date": "Дата",
|
||||
@ -82,7 +82,7 @@
|
||||
"Groups": "Группы",
|
||||
"Has full access to space settings and pages.": "Имеет полный доступ к настройкам пространства и страницам.",
|
||||
"Home": "Главная",
|
||||
"Import pages": "Импортировать страницы",
|
||||
"Import pages": "Импорт страниц",
|
||||
"Import pages & space settings": "Импорт страниц и настройки пространства",
|
||||
"Importing pages": "Импортирование страниц",
|
||||
"invalid invitation link": "ссылка на приглашение недействительна",
|
||||
@ -92,7 +92,7 @@
|
||||
"Invite new members": "Пригласить новых участников",
|
||||
"Invited members who are yet to accept their invitation will appear here.": "Приглашённые участники, которые ещё не приняли приглашение, появятся здесь.",
|
||||
"Invited members will be granted access to spaces the groups can access": "Приглашённые участники получат доступ к пространствам, доступ к которым есть у группы",
|
||||
"Join the workspace": "Присоединиться к рабочему пространству",
|
||||
"Join the workspace": "Присоединиться к рабочей области",
|
||||
"Language": "Язык",
|
||||
"Light": "Светлая",
|
||||
"Link copied": "Ссылка скопирована",
|
||||
@ -128,7 +128,7 @@
|
||||
"Password changed successfully": "Пароль успешно изменён",
|
||||
"Pending": "В ожидании",
|
||||
"Please confirm your action": "Пожалуйста, подтвердите ваше действие",
|
||||
"Preferences": "Внешний вид",
|
||||
"Preferences": "Настройки",
|
||||
"Print PDF": "Печать PDF",
|
||||
"Profile": "Профиль",
|
||||
"Recently updated": "Обновлено недавно",
|
||||
@ -148,8 +148,9 @@
|
||||
"Select role to assign to all invited members": "Выберите роль для всех приглашённых участников",
|
||||
"Select theme": "Выберите тему",
|
||||
"Send invitation": "Отправить приглашение",
|
||||
"Invitation sent": "Приглашение отправлено",
|
||||
"Settings": "Настройки",
|
||||
"Setup workspace": "Настроить рабочее пространство",
|
||||
"Setup workspace": "Настроить рабочую область",
|
||||
"Sign In": "Вход",
|
||||
"Sign Up": "Регистрация",
|
||||
"Slug": "Slug",
|
||||
@ -176,9 +177,9 @@
|
||||
"Untitled": "Без названия",
|
||||
"Updated successfully": "Обновлено успешно",
|
||||
"User": "Пользователь",
|
||||
"Workspace": "Рабочее пространство",
|
||||
"Workspace Name": "Имя рабочего пространства",
|
||||
"Workspace settings": "Настройки рабочего пространства",
|
||||
"Workspace": "Рабочая область",
|
||||
"Workspace Name": "Имя рабочей области",
|
||||
"Workspace settings": "Настройки рабочей области",
|
||||
"You can change your password here.": "Вы можете изменить свой пароль здесь.",
|
||||
"Your Email": "Ваш адрес электронной почты",
|
||||
"Your import is complete.": "Ваш импорт завершен.",
|
||||
@ -216,9 +217,9 @@
|
||||
"Revoke invitation": "Отозвать приглашение",
|
||||
"Revoke": "Отозвать",
|
||||
"Don't": "Нет",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочему пространству.",
|
||||
"Are you sure you want to revoke this invitation? The user will not be able to join the workspace.": "Вы уверены, что хотите отозвать это приглашение? Пользователь не сможет присоединиться к рабочей области.",
|
||||
"Resend invitation": "Отправить приглашение повторно",
|
||||
"Anyone with this link can join this workspace.": "Любой, у кого есть эта ссылка, может присоединиться к этому рабочему пространству.",
|
||||
"Anyone with this link can join this workspace.": "Любой, у кого есть данная ссылка, может присоединиться к этой рабочей области.",
|
||||
"Invite link": "Ссылка для приглашения",
|
||||
"Copy": "Копировать",
|
||||
"Copied": "Скопировано",
|
||||
@ -244,12 +245,13 @@
|
||||
"Align left": "По левому краю",
|
||||
"Align right": "По правому краю",
|
||||
"Align center": "По центру",
|
||||
"Justify": "По ширине",
|
||||
"Merge cells": "Объединить ячейки",
|
||||
"Split cell": "Разделить ячейку",
|
||||
"Delete column": "Удалить столбец",
|
||||
"Delete row": "Удалить строку",
|
||||
"Add left column": "Добавить левый столбец",
|
||||
"Add right column": "Добавить правый столбец",
|
||||
"Add left column": "Добавить столбец слева",
|
||||
"Add right column": "Добавить столбец справа",
|
||||
"Add row above": "Добавить строку выше",
|
||||
"Add row below": "Добавить строку ниже",
|
||||
"Delete table": "Удалить таблицу",
|
||||
@ -320,23 +322,66 @@
|
||||
"Quote": "Цитата",
|
||||
"Image": "Изображение",
|
||||
"File attachment": "Прикрепленный файл",
|
||||
"Toggle block": "Переключить блок",
|
||||
"Toggle block": "Сворачиваемый блок",
|
||||
"Callout": "Выноска",
|
||||
"Insert callout notice.": "Вставить выноску с сообщением.",
|
||||
"Math inline": "Формула в строке",
|
||||
"Math inline": "Формула",
|
||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||
"Math block": "Блок формул",
|
||||
"Insert math equation": "Вставить математическое выражение",
|
||||
"Mermaid diagram": "Диаграмма Mermaid",
|
||||
"Insert mermaid diagram": "Вставить диаграмму Mermaid",
|
||||
"Insert and design Drawio diagrams": "Вставьте и редактируйте диаграммы Draw.io",
|
||||
"Insert and design Drawio diagrams": "Вставить и рисовать диаграммы Draw.io",
|
||||
"Insert current date": "Вставить текущую дату",
|
||||
"Draw and sketch excalidraw diagrams": "Создайте и рисуйте диаграммы Excalidraw",
|
||||
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
|
||||
"Multiple": "Несколько",
|
||||
"Heading {{level}}": "Заголовок {{level}}",
|
||||
"Toggle title": "Переключить заголовок",
|
||||
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для выбора команд",
|
||||
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
|
||||
"Names do not match": "Названия не совпадают",
|
||||
"Today, {{time}}": "Сегодня, {{time}}",
|
||||
"Yesterday, {{time}}": "Вчера, {{time}}"
|
||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||
"Space created successfully": "Пространство успешно создано",
|
||||
"Space updated successfully": "Пространство успешно обновлено",
|
||||
"Space deleted successfully": "Пространство успешно удалено",
|
||||
"Members added successfully": "Участники успешно добавлены",
|
||||
"Member removed successfully": "Участник успешно удален",
|
||||
"Member role updated successfully": "Роль участника успешно обновлена",
|
||||
"Created by: <b>{{creatorName}}</b>": "Автор: <b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "Дата создания: {{time}}",
|
||||
"Edited by {{name}} {{time}}": "Изменено {{name}} {{time}}",
|
||||
"Word count: {{wordCount}}": "Количество слов: {{wordCount}}",
|
||||
"Character count: {{characterCount}}": "Количество символов: {{characterCount}}",
|
||||
"New update": "Новое обновление",
|
||||
"{{latestVersion}} is available": "Доступна новая версия {{latestVersion}}",
|
||||
"Delete member": "Удалить участника",
|
||||
"Member deleted successfully": "Участник успешно удален",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "Вы уверены, что хотите удалить этого участника рабочей области? Это действие необратимо.",
|
||||
"Move": "Переместить",
|
||||
"Move page": "Переместить страницу",
|
||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||
"Table of contents": "Содержание",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||
"Share": "Поделиться",
|
||||
"Public sharing": "Общий доступ",
|
||||
"Shared by": "Поделился",
|
||||
"Shared at": "Поделился в",
|
||||
"Inherits public sharing from": "Наследует общий доступ от",
|
||||
"Share to web": "Поделиться в интернете",
|
||||
"Shared to web": "Размещено в интернете",
|
||||
"Anyone with the link can view this page": "Любой, у кого есть ссылка, может просмотреть эту страницу",
|
||||
"Make this page publicly accessible": "Сделать эту страницу общедоступной",
|
||||
"Include sub-pages": "Включить подстраницы",
|
||||
"Make sub-pages public too": "Сделать подстраницы также общедоступными",
|
||||
"Allow search engines to index page": "Разрешить поисковым системам индексировать страницу",
|
||||
"Open page": "Открыть страницу",
|
||||
"Page": "Страница",
|
||||
"Delete public share link": "Удалить ссылку на общий доступ",
|
||||
"Delete share": "Удалить общий доступ",
|
||||
"Are you sure you want to delete this shared link?": "Вы уверены, что хотите удалить эту ссылку общего доступа?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "Общие страницы из пространств, участником которых вы являетесь, появятся здесь",
|
||||
"Share deleted successfully": "Общий доступ успешно удален",
|
||||
"Share not found": "Общий доступ не найден",
|
||||
"Failed to share page": "Не удалось поделиться страницей"
|
||||
}
|
||||
|
||||
@ -148,6 +148,7 @@
|
||||
"Select role to assign to all invited members": "选择要分配给所有被邀请成员的角色",
|
||||
"Select theme": "选择主题",
|
||||
"Send invitation": "发送邀请",
|
||||
"Invitation sent": "邀请邮件已发送",
|
||||
"Settings": "设置",
|
||||
"Setup workspace": "设置工作空间",
|
||||
"Sign In": "登录",
|
||||
@ -244,6 +245,7 @@
|
||||
"Align left": "靠左对齐",
|
||||
"Align right": "靠右对齐",
|
||||
"Align center": "居中对齐",
|
||||
"Justify": "两端对齐",
|
||||
"Merge cells": "合并单元格",
|
||||
"Split cell": "分割单元格",
|
||||
"Delete column": "删除整列",
|
||||
@ -296,7 +298,7 @@
|
||||
"Heading 2": "2 级标题",
|
||||
"Heading 3": "3 级标题",
|
||||
"To-do List": "代办列表",
|
||||
"Bullet List": "无需列表",
|
||||
"Bullet List": "无序列表",
|
||||
"Numbered List": "有序列表",
|
||||
"Blockquote": "引用块",
|
||||
"Just start typing with plain text.": "只需开始键入纯文本",
|
||||
@ -338,5 +340,48 @@
|
||||
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
|
||||
"Names do not match": "名称不匹配",
|
||||
"Today, {{time}}": "今天,{{time}}",
|
||||
"Yesterday, {{time}}": "昨天,{{time}}"
|
||||
"Yesterday, {{time}}": "昨天,{{time}}",
|
||||
"Space created successfully": "空间创建成功",
|
||||
"Space updated successfully": "空间更新成功",
|
||||
"Space deleted successfully": "空间已成功删除",
|
||||
"Members added successfully": "成员添加成功",
|
||||
"Member removed successfully": "成员移除成功",
|
||||
"Member role updated successfully": "成员角色更新成功",
|
||||
"Created by: <b>{{creatorName}}</b>": "创建者:<b>{{creatorName}}</b>",
|
||||
"Created at: {{time}}": "创建于:{{time}}",
|
||||
"Edited by {{name}} {{time}}": "由{{name}} 编辑于 {{time}}",
|
||||
"Word count: {{wordCount}}": "字数:{{wordCount}}",
|
||||
"Character count: {{characterCount}}": "字符数:{{characterCount}}",
|
||||
"New update": "新更新",
|
||||
"{{latestVersion}} is available": "{{latestVersion}} 已经可以使用",
|
||||
"Delete member": "删除成员",
|
||||
"Member deleted successfully": "成员删除成功",
|
||||
"Are you sure you want to delete this workspace member? This action is irreversible.": "您确定要删除此工作区成员吗?此操作不可逆。",
|
||||
"Move": "移动",
|
||||
"Move page": "移动页面",
|
||||
"Move page to a different space.": "将页面移动到不同的空间。",
|
||||
"Real-time editor connection lost. Retrying...": "实时编辑器连接丢失。重试中……",
|
||||
"Table of contents": "目录",
|
||||
"Add headings (H1, H2, H3) to generate a table of contents.": "添加标题(H1,H2,H3)以生成目录。",
|
||||
"Share": "分享",
|
||||
"Public sharing": "公开分享",
|
||||
"Shared by": "分享者",
|
||||
"Shared at": "分享时间",
|
||||
"Inherits public sharing from": "继承自的公开分享",
|
||||
"Share to web": "分享到网页",
|
||||
"Shared to web": "已分享到网页",
|
||||
"Anyone with the link can view this page": "任何有链接的人都可以查看此页面",
|
||||
"Make this page publicly accessible": "使此页面可公开访问",
|
||||
"Include sub-pages": "包括子页面",
|
||||
"Make sub-pages public too": "将子页面也设为公开",
|
||||
"Allow search engines to index page": "允许搜索引擎索引页面",
|
||||
"Open page": "打开页面",
|
||||
"Page": "页面",
|
||||
"Delete public share link": "删除公开分享链接",
|
||||
"Delete share": "删除分享",
|
||||
"Are you sure you want to delete this shared link?": "您确定要删除此分享链接吗?",
|
||||
"Publicly shared pages from spaces you are a member of will appear here": "您所在空间的公开共享页面会显示在此处",
|
||||
"Share deleted successfully": "分享已成功删除",
|
||||
"Share not found": "未找到分享",
|
||||
"Failed to share page": "页面分享失败"
|
||||
}
|
||||
|
||||
@ -18,10 +18,24 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||
import PasswordReset from "./pages/auth/password-reset";
|
||||
import Billing from "@/ee/billing/pages/billing.tsx";
|
||||
import CloudLogin from "@/ee/pages/cloud-login.tsx";
|
||||
import CreateWorkspace from "@/ee/pages/create-workspace.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Security from "@/ee/security/pages/security.tsx";
|
||||
import License from "@/ee/licence/pages/license.tsx";
|
||||
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from '@/pages/share/share-redirect.tsx';
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
useRedirectToCloudSelect();
|
||||
useTrackOrigin();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -29,15 +43,30 @@ export default function App() {
|
||||
<Route index element={<Navigate to="/home" />} />
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
<Route path={"/forgot-password"} element={<ForgotPassword />} />
|
||||
<Route path={"/password-reset"} element={<PasswordReset />} />
|
||||
|
||||
{!isCloud() && (
|
||||
<Route path={"/setup/register"} element={<SetupWorkspace />} />
|
||||
)}
|
||||
|
||||
{isCloud() && (
|
||||
<>
|
||||
<Route path={"/create"} element={<CreateWorkspace />} />
|
||||
<Route path={"/select"} element={<CloudLogin />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Route element={<ShareLayout />}>
|
||||
<Route path={"/share/:shareId/p/:pageSlug"} element={<SharedPage />} />
|
||||
<Route path={"/share/p/:pageSlug"} element={<SharedPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={"/share/:shareId"} element={<ShareRedirect />} />
|
||||
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />
|
||||
|
||||
<Route element={<Layout />}>
|
||||
<Route path={"/home"} element={<Home />} />
|
||||
|
||||
<Route path={"/s/:spaceSlug"} element={<SpaceHome />} />
|
||||
<Route
|
||||
path={"/s/:spaceSlug/p/:pageSlug"}
|
||||
@ -61,6 +90,10 @@ export default function App() {
|
||||
<Route path={"groups"} element={<Groups />} />
|
||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||
<Route path={"spaces"} element={<Spaces />} />
|
||||
<Route path={"sharing"} element={<Shares />} />
|
||||
<Route path={"security"} element={<Security />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
||||
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"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>Export {type}</Modal.Title>
|
||||
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
|
||||
@ -21,7 +21,7 @@ export default function Paginate({
|
||||
}
|
||||
|
||||
return (
|
||||
<Group mt="md">
|
||||
<Group mt="md" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
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 React from "react";
|
||||
import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
||||
import {Link} from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import {useAtom} from "jotai/index";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import {useToggleSidebar} from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useTrial from "@/ee/hooks/use-trial.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
|
||||
const links = [{link: APP_ROUTE.HOME, label: "Home"}];
|
||||
const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
|
||||
|
||||
export function AppHeader() {
|
||||
const { t } = useTranslation();
|
||||
@ -22,6 +24,7 @@ export function AppHeader() {
|
||||
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
|
||||
const { isTrial, trialDaysLeft } = useTrial();
|
||||
|
||||
const isHomeRoute = location.pathname.startsWith("/home");
|
||||
|
||||
@ -38,7 +41,6 @@ export function AppHeader() {
|
||||
{!isHomeRoute && (
|
||||
<>
|
||||
<Tooltip label={t("Sidebar toggle")}>
|
||||
|
||||
<SidebarToggle
|
||||
aria-label={t("Sidebar toggle")}
|
||||
opened={mobileOpened}
|
||||
@ -63,7 +65,7 @@ export function AppHeader() {
|
||||
<Text
|
||||
size="lg"
|
||||
fw={600}
|
||||
style={{cursor: "pointer", userSelect: "none"}}
|
||||
style={{ cursor: "pointer", userSelect: "none" }}
|
||||
component={Link}
|
||||
to="/home"
|
||||
>
|
||||
@ -75,8 +77,21 @@ export function AppHeader() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group px={"xl"}>
|
||||
<TopMenu/>
|
||||
<Group px={"xl"} wrap="nowrap">
|
||||
{isCloud() && isTrial && trialDaysLeft !== 0 && (
|
||||
<Badge
|
||||
variant="light"
|
||||
style={{ cursor: "pointer" }}
|
||||
component={Link}
|
||||
to={APP_ROUTE.SETTINGS.WORKSPACE.BILLING}
|
||||
visibleFrom="xs"
|
||||
>
|
||||
{trialDaysLeft === 1
|
||||
? "1 day left"
|
||||
: `${trialDaysLeft} days left`}
|
||||
</Badge>
|
||||
)}
|
||||
<TopMenu />
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@ -4,10 +4,14 @@ import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TableOfContents } from "@/features/editor/components/table-of-contents/table-of-contents.tsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
|
||||
let title: string;
|
||||
let component: ReactNode;
|
||||
@ -17,6 +21,10 @@ export default function Aside() {
|
||||
component = <CommentList />;
|
||||
title = "Comments";
|
||||
break;
|
||||
case "toc":
|
||||
component = <TableOfContents editor={pageEditor} />;
|
||||
title = "Table of contents";
|
||||
break;
|
||||
default:
|
||||
component = null;
|
||||
title = null;
|
||||
|
||||
@ -1,24 +1,29 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
asideStateAtom,
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom, sidebarWidthAtom,
|
||||
mobileSidebarAtom,
|
||||
sidebarWidthAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useTrialEndAction } from "@/ee/hooks/use-trial-end-action.tsx";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useTrialEndAction();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
@ -37,7 +42,9 @@ export default function GlobalAppShell({
|
||||
const resize = React.useCallback(
|
||||
(mouseMoveEvent) => {
|
||||
if (isResizing) {
|
||||
const newWidth = mouseMoveEvent.clientX - sidebarRef.current.getBoundingClientRect().left;
|
||||
const newWidth =
|
||||
mouseMoveEvent.clientX -
|
||||
sidebarRef.current.getBoundingClientRect().left;
|
||||
if (newWidth < 220) {
|
||||
setSidebarWidth(220);
|
||||
return;
|
||||
@ -49,7 +56,7 @@ export default function GlobalAppShell({
|
||||
setSidebarWidth(newWidth);
|
||||
}
|
||||
},
|
||||
[isResizing]
|
||||
[isResizing],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -94,7 +101,11 @@ export default function GlobalAppShell({
|
||||
<AppHeader />
|
||||
</AppShell.Header>
|
||||
{!isHomeRoute && (
|
||||
<AppShell.Navbar className={classes.navbar} withBorder={false} ref={sidebarRef}>
|
||||
<AppShell.Navbar
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
>
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
{isSpaceRoute && <SpaceSidebar />}
|
||||
{isSettingsRoute && <SettingsSidebar />}
|
||||
@ -102,7 +113,7 @@ export default function GlobalAppShell({
|
||||
)}
|
||||
<AppShell.Main>
|
||||
{isSettingsRoute ? (
|
||||
<Container size={800}>{children}</Container>
|
||||
<Container size={850}>{children}</Container>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@ -20,4 +20,4 @@ export const asideStateAtom = atom<AsideStateType>({
|
||||
isAsideOpen: false,
|
||||
});
|
||||
|
||||
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||
export const sidebarWidthAtom = atomWithWebStorage<number>('sidebarWidth', 300);
|
||||
@ -33,13 +33,13 @@ export default function TopMenu() {
|
||||
<UnstyledButton>
|
||||
<Group gap={7} wrap={"nowrap"}>
|
||||
<CustomAvatar
|
||||
avatarUrl={workspace.logo}
|
||||
name={workspace.name}
|
||||
avatarUrl={workspace?.logo}
|
||||
name={workspace?.name}
|
||||
variant="filled"
|
||||
size="sm"
|
||||
/>
|
||||
<Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
|
||||
{workspace.name}
|
||||
{workspace?.name}
|
||||
</Text>
|
||||
<IconChevronDown size={16} />
|
||||
</Group>
|
||||
|
||||
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 { Group, Text, ScrollArea, ActionIcon, rem } from "@mantine/core";
|
||||
import { Group, Text, ScrollArea, ActionIcon } from "@mantine/core";
|
||||
import {
|
||||
IconUser,
|
||||
IconSettings,
|
||||
@ -8,15 +8,40 @@ import {
|
||||
IconUsersGroup,
|
||||
IconSpaces,
|
||||
IconBrush,
|
||||
IconCoin,
|
||||
IconLock,
|
||||
IconKey,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import {
|
||||
prefetchBilling,
|
||||
prefetchGroups,
|
||||
prefetchLicense,
|
||||
prefetchShares,
|
||||
prefetchSpaces,
|
||||
prefetchSsoProviders,
|
||||
prefetchWorkspaceMembers,
|
||||
} from "@/components/settings/settings-queries.tsx";
|
||||
import AppVersion from "@/components/settings/app-version.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
|
||||
|
||||
interface DataItem {
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
path: string;
|
||||
isCloud?: boolean;
|
||||
isEnterprise?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isSelfhosted?: boolean;
|
||||
}
|
||||
|
||||
interface DataGroup {
|
||||
@ -45,8 +70,34 @@ const groupedData: DataGroup[] = [
|
||||
icon: IconUsers,
|
||||
path: "/settings/members",
|
||||
},
|
||||
{
|
||||
label: "Billing",
|
||||
icon: IconCoin,
|
||||
path: "/settings/billing",
|
||||
isCloud: true,
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
label: "Security & SSO",
|
||||
icon: IconLock,
|
||||
path: "/settings/security",
|
||||
isCloud: true,
|
||||
isEnterprise: true,
|
||||
isAdmin: true,
|
||||
},
|
||||
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "System",
|
||||
items: [
|
||||
{
|
||||
label: "License & Edition",
|
||||
icon: IconKey,
|
||||
path: "/settings/license",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -55,36 +106,117 @@ export default function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState(location.pathname);
|
||||
const navigate = useNavigate();
|
||||
const { goBack } = useSettingsNavigation();
|
||||
const { isAdmin } = useUserRole();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobileSidebar = useToggleSidebar(mobileSidebarAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
const menuItems = groupedData.map((group) => (
|
||||
<div key={group.heading}>
|
||||
<Text c="dimmed" className={classes.linkHeader}>
|
||||
{t(group.heading)}
|
||||
</Text>
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
const canShowItem = (item: DataItem) => {
|
||||
if (item.isCloud && item.isEnterprise) {
|
||||
if (!(isCloud() || workspace?.hasLicenseKey)) return false;
|
||||
return item.isAdmin ? isAdmin : true;
|
||||
}
|
||||
|
||||
if (item.isCloud) {
|
||||
return isCloud() ? (item.isAdmin ? isAdmin : true) : false;
|
||||
}
|
||||
|
||||
if (item.isSelfhosted) {
|
||||
return !isCloud() ? (item.isAdmin ? isAdmin : true) : false;
|
||||
}
|
||||
|
||||
if (item.isEnterprise) {
|
||||
return workspace?.hasLicenseKey ? (item.isAdmin ? isAdmin : true) : false;
|
||||
}
|
||||
|
||||
if (item.isAdmin) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const menuItems = groupedData.map((group) => {
|
||||
if (group.heading === "System" && (!isAdmin || isCloud())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.heading}>
|
||||
<Text c="dimmed" className={classes.linkHeader}>
|
||||
{t(group.heading)}
|
||||
</Text>
|
||||
{group.items.map((item) => {
|
||||
if (!canShowItem(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prefetchHandler: any;
|
||||
switch (item.label) {
|
||||
case "Members":
|
||||
prefetchHandler = prefetchWorkspaceMembers;
|
||||
break;
|
||||
case "Spaces":
|
||||
prefetchHandler = prefetchSpaces;
|
||||
break;
|
||||
case "Groups":
|
||||
prefetchHandler = prefetchGroups;
|
||||
break;
|
||||
case "Billing":
|
||||
prefetchHandler = prefetchBilling;
|
||||
break;
|
||||
case "License & Edition":
|
||||
if (workspace?.hasLicenseKey) {
|
||||
prefetchHandler = prefetchLicense;
|
||||
}
|
||||
break;
|
||||
case "Security & SSO":
|
||||
prefetchHandler = prefetchSsoProviders;
|
||||
break;
|
||||
case "Public sharing":
|
||||
prefetchHandler = prefetchShares;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
onMouseEnter={prefetchHandler}
|
||||
className={classes.link}
|
||||
data-active={active.startsWith(item.path) || undefined}
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={2} />
|
||||
<span>{t(item.label)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes.navbar}>
|
||||
<Group className={classes.title} justify="flex-start">
|
||||
<ActionIcon
|
||||
onClick={() => navigate(-1)}
|
||||
onClick={() => {
|
||||
goBack();
|
||||
if (mobileSidebarOpened) {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
}}
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label="Back"
|
||||
@ -95,18 +227,21 @@ export default function SettingsSidebar() {
|
||||
</Group>
|
||||
|
||||
<ScrollArea w="100%">{menuItems}</ScrollArea>
|
||||
<div className={classes.version}>
|
||||
<Text
|
||||
className={classes.version}
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component="a"
|
||||
href="https://github.com/docmost/docmost/releases"
|
||||
target="_blank"
|
||||
>
|
||||
v{APP_VERSION}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{!isCloud() && <AppVersion />}
|
||||
|
||||
{isCloud() && (
|
||||
<div className={classes.text}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
component="a"
|
||||
href="mailto:help@docmost.com"
|
||||
>
|
||||
help@docmost.com
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.version {
|
||||
.text {
|
||||
padding-left: var(--mantine-spacing-xs) ;
|
||||
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() {
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
return (
|
||||
<Group justify="center" mt="xl">
|
||||
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||
<Button onClick={() => setColorScheme('auto')}>Auto</Button>
|
||||
</Group>
|
||||
);
|
||||
return (
|
||||
<Tooltip label="Toggle Color Scheme">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
||||
}}
|
||||
aria-label="Toggle color scheme"
|
||||
>
|
||||
<IconSun className={classes.light} size={18} stroke={1.5} />
|
||||
<IconMoon className={classes.dark} size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@ -68,8 +68,8 @@ function EmojiPicker({
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<Popover.Dropdown bg="000" style={{ border: "none" }} ref={setDropdown}>
|
||||
<Picker
|
||||
data={async () => (await import("@emoji-mart/data")).default}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
@ -77,22 +77,22 @@ function EmojiPicker({
|
||||
skinTonePosition="search"
|
||||
theme={colorScheme}
|
||||
/>
|
||||
</Suspense>
|
||||
<Button
|
||||
variant="default"
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
</Popover.Dropdown>
|
||||
<Button
|
||||
variant="default"
|
||||
c="gray"
|
||||
size="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
}}
|
||||
onClick={handleRemoveEmoji}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
</Popover.Dropdown>
|
||||
</Suspense>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
130
apps/client/src/ee/billing/components/billing-details.tsx
Normal file
130
apps/client/src/ee/billing/components/billing-details.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
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}
|
||||
>
|
||||
Total
|
||||
</Text>
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
115
apps/client/src/ee/billing/components/billing-plans.tsx
Normal file
115
apps/client/src/ee/billing/components/billing-plans.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
List,
|
||||
SegmentedControl,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Text,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useBillingPlans } from "@/ee/billing/queries/billing-query.ts";
|
||||
import { getCheckoutLink } from "@/ee/billing/services/billing-service.ts";
|
||||
|
||||
export default function BillingPlans() {
|
||||
const { data: plans } = useBillingPlans();
|
||||
const [interval, setInterval] = useState("yearly");
|
||||
|
||||
if (!plans) {
|
||||
return 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
{plans.map((plan) => {
|
||||
const price =
|
||||
interval === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
const priceId = interval === "monthly" ? plan.monthlyId : plan.yearlyId;
|
||||
const yearlyMonthPrice = parseInt(plan.price.yearly) / 12;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.name}
|
||||
withBorder
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
p="xl"
|
||||
w={300}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
fullWidth
|
||||
data={[
|
||||
{ label: "Monthly", value: "monthly" },
|
||||
{ label: "Yearly (25% OFF)", value: "yearly" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Title order={3} ta="center" mt="sm" mb="xs">
|
||||
{plan.name}
|
||||
</Title>
|
||||
<Text ta="center" size="lg" fw={700}>
|
||||
{interval === "monthly" && (
|
||||
<>
|
||||
${price}{" "}
|
||||
<Text span size="sm" fw={500} c="dimmed">
|
||||
/user/month
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{interval === "yearly" && (
|
||||
<>
|
||||
${yearlyMonthPrice}{" "}
|
||||
<Text span size="sm" fw={500} c="dimmed">
|
||||
/user/month
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<br/>
|
||||
<Text span ta="center" size="md" fw={500} c="dimmed">
|
||||
billed {interval}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<Card.Section mt="lg">
|
||||
<Button onClick={() => handleCheckout(priceId)} fullWidth>
|
||||
Subscribe
|
||||
</Button>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section mt="md">
|
||||
<List
|
||||
spacing="xs"
|
||||
size="sm"
|
||||
center
|
||||
icon={
|
||||
<ThemeIcon variant="light" size={24} radius="xl">
|
||||
<IconCheck size={16} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{plan.features.map((feature, index) => (
|
||||
<List.Item key={index}>{feature}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
50
apps/client/src/ee/billing/types/billing.types.ts
Normal file
50
apps/client/src/ee/billing/types/billing.types.ts
Normal file
@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
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;
|
||||
border-radius: 4px;
|
||||
background: light-dark(var(--mantine-color-body), rgba(0, 0, 0, 0.1));
|
||||
margin-top: 150px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
margin-top: 50px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.containerBox {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -35,8 +35,8 @@ export function ForgotPasswordForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Forgot password")}
|
||||
</Title>
|
||||
|
||||
@ -65,8 +65,8 @@ export function InviteSignUpForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Join the workspace")}
|
||||
</Title>
|
||||
|
||||
@ -10,12 +10,17 @@ import {
|
||||
PasswordInput,
|
||||
Box,
|
||||
Anchor,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import classes from "./auth.module.css";
|
||||
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SsoLogin from "@/ee/components/sso-login.tsx";
|
||||
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import React from "react";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z
|
||||
@ -29,6 +34,12 @@ export function LoginForm() {
|
||||
const { t } = useTranslation();
|
||||
const { signIn, isLoading } = useAuth();
|
||||
useRedirectIfAuthenticated();
|
||||
const {
|
||||
data,
|
||||
isLoading: isDataLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useWorkspacePublicDataQuery();
|
||||
|
||||
const form = useForm<ILogin>({
|
||||
validate: zodResolver(formSchema),
|
||||
@ -42,44 +53,60 @@ export function LoginForm() {
|
||||
await signIn(data);
|
||||
}
|
||||
|
||||
if (isDataLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isError && error?.["response"]?.status === 404) {
|
||||
return <Error404 />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Login")}
|
||||
</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
placeholder="email@example.com"
|
||||
variant="filled"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<SsoLogin />
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
{!data?.enforceSso && (
|
||||
<>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Email")}
|
||||
placeholder="email@example.com"
|
||||
variant="filled"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
{t("Sign In")}
|
||||
</Button>
|
||||
</form>
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Your password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
|
||||
<Anchor
|
||||
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
|
||||
component={Link}
|
||||
underline="never"
|
||||
size="sm"
|
||||
>
|
||||
{t("Forgot your password?")}
|
||||
</Anchor>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Anchor
|
||||
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
|
||||
component={Link}
|
||||
underline="never"
|
||||
size="sm"
|
||||
>
|
||||
{t("Forgot your password?")}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
||||
<Button type="submit" fullWidth mt="md" loading={isLoading}>
|
||||
{t("Sign In")}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@ -37,8 +37,8 @@ export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Password reset")}
|
||||
</Title>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
Container,
|
||||
@ -9,11 +8,17 @@ import {
|
||||
Button,
|
||||
PasswordInput,
|
||||
Box,
|
||||
Anchor,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ISetupWorkspace } from "@/features/auth/types/auth.types";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SsoCloudSignup from "@/ee/components/sso-cloud-signup.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { Link } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
workspaceName: z.string().trim().min(3).max(50),
|
||||
@ -45,55 +50,71 @@ export function SetupWorkspaceForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={40} className={classes.container}>
|
||||
<Box p="xl" mt={200}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Create workspace")}
|
||||
</Title>
|
||||
<div>
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Create workspace")}
|
||||
</Title>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
{isCloud() && <SsoCloudSignup />}
|
||||
|
||||
<TextInput
|
||||
id="name"
|
||||
type="text"
|
||||
label={t("Your Name")}
|
||||
placeholder={t("enter your full name")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<TextInput
|
||||
id="workspaceName"
|
||||
type="text"
|
||||
label={t("Workspace Name")}
|
||||
placeholder={t("e.g ACME Inc")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("workspaceName")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Your Email")}
|
||||
placeholder="email@example.com"
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<TextInput
|
||||
id="name"
|
||||
type="text"
|
||||
label={t("Your Name")}
|
||||
placeholder={t("enter your full name")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Enter a strong password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
{t("Setup workspace")}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
<TextInput
|
||||
id="email"
|
||||
type="email"
|
||||
label={t("Your Email")}
|
||||
placeholder="email@example.com"
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("Password")}
|
||||
placeholder={t("Enter a strong password")}
|
||||
variant="filled"
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
|
||||
{t("Create workspace")}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Container>
|
||||
{isCloud() && (
|
||||
<Text ta="center">
|
||||
{t("Already part of an existing workspace?")}{" "}
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={APP_ROUTE.AUTH.SELECT_WORKSPACE}
|
||||
fw={500}
|
||||
>
|
||||
{t("Sign-in")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,10 +19,15 @@ import {
|
||||
} from "@/features/auth/types/auth.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
|
||||
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
|
||||
import {
|
||||
acceptInvitation,
|
||||
createWorkspace,
|
||||
} from "@/features/workspace/services/workspace-service.ts";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@ -67,9 +72,21 @@ export default function useAuth() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await setupWorkspace(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
if (isCloud()) {
|
||||
const res = await createWorkspace(data);
|
||||
const hostname = res?.workspace?.hostname;
|
||||
const exchangeToken = res?.exchangeToken;
|
||||
if (hostname && exchangeToken) {
|
||||
window.location.href = exchangeTokenRedirectUrl(
|
||||
hostname,
|
||||
exchangeToken,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const res = await setupWorkspace(data);
|
||||
setIsLoading(false);
|
||||
navigate(APP_ROUTE.HOME);
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
notifications.show({
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
export function useVerifyUserTokenQuery(
|
||||
verify: IVerifyUserToken,
|
||||
@ -17,9 +18,17 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["collab-token"],
|
||||
queryFn: () => getCollabToken(),
|
||||
staleTime: 24 * 60 * 60 * 1000, //24hrs
|
||||
refetchInterval: 20 * 60 * 60 * 1000, //20hrs
|
||||
retry: 10,
|
||||
staleTime: 20 * 60 * 60 * 1000, //20hrs
|
||||
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
||||
//refetchIntervalInBackground: true,
|
||||
refetchOnMount: true,
|
||||
//@ts-ignore
|
||||
retry: (failureCount, error) => {
|
||||
if (isAxiosError(error) && error.response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
return 10;
|
||||
},
|
||||
retryDelay: (retryAttempt) => {
|
||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user