Compare commits

..

54 Commits

Author SHA1 Message Date
Girish Ramakrishnan 9470654394 9.0.7 changes 2025-11-04 09:22:15 +01:00
Girish Ramakrishnan 28feadd6c5 typo: forgot to amend previous commit 2025-11-04 09:20:12 +01:00
Girish Ramakrishnan af3ed04b7f externalldap: only set group members if they changed 2025-11-04 09:12:25 +01:00
Girish Ramakrishnan 2da99673cd do not store "null" as string in database
in other news, JSON.parse('null') returns null.
2025-11-04 09:02:58 +01:00
Girish Ramakrishnan 476adcb029 show upstreamVersion and not package version 2025-11-03 17:04:03 +01:00
Johannes Zellner b2c8f87276 Auto-dismiss notifications popover if no unread notifications exist 2025-11-03 15:32:01 +01:00
Girish Ramakrishnan bd4e132709 More changes 2025-11-03 13:24:15 +01:00
Johannes Zellner fa8fcf8761 Support wildcard domain aliases in app location form
fixes #870
2025-11-03 12:00:00 +01:00
Johannes Zellner 8e92b53d9f Show app icons in the grid in grayscale if app is stopped 2025-11-03 11:28:54 +01:00
Girish Ramakrishnan 6f90bd3db0 9.0.6 changes 2025-11-03 10:45:52 +01:00
Johannes Zellner a261d8b754 Do not allow unlinking from cloudron.io account in demo mode 2025-10-31 08:47:05 +01:00
Johannes Zellner 9643b7ed1b Filter dropdowns are searchable with more than 10 entries 2025-10-30 16:06:47 +01:00
Johannes Zellner ec191d51bc Sort apps in the grid by label 2025-10-30 16:01:03 +01:00
Johannes Zellner a5452e4b15 Fix filemanager for custom apps 2025-10-27 16:29:31 +01:00
Johannes Zellner 8522802f85 Update translations 2025-10-27 08:48:24 +01:00
Girish Ramakrishnan 6f2e3afe07 email: Fix display of inbound domains 2025-10-22 19:31:59 +02:00
Girish Ramakrishnan 70dfb41d95 email domains: fix display of stats 2025-10-22 19:23:15 +02:00
Girish Ramakrishnan 34f04828c5 Fix casing in translations
dashboard/README.md has information of the casing style
2025-10-22 18:40:20 +02:00
Girish Ramakrishnan a78799973d translation string typo 2025-10-22 18:33:12 +02:00
Girish Ramakrishnan 1797148951 warning label should appear above advanced 2025-10-22 16:43:33 +02:00
Girish Ramakrishnan 67caa89591 Treescale is gone 2025-10-22 14:53:24 +02:00
Girish Ramakrishnan e3a88e9f5b change default dns provider to digitalocean
hetzner provider is getting obsoleted and hetznercloud provider is in beta
2025-10-22 13:30:34 +02:00
Girish Ramakrishnan e9910c9b95 fix casing in a few places 2025-10-22 12:37:50 +02:00
Johannes Zellner 45e058bdc1 Use translated string for outbound in email domains view 2025-10-22 12:17:05 +02:00
Girish Ramakrishnan 9af5404921 add translation text notes 2025-10-22 11:34:08 +02:00
Johannes Zellner 5c4ca1b699 Make backup content list a TableView so we can sort it by size and fileCount 2025-10-21 23:56:16 +02:00
Johannes Zellner b6827736db All settings in sidebar should be same icon 2025-10-21 22:53:37 +02:00
Johannes Zellner aada3f3979 Autofocus search in appstore view 2025-10-21 22:33:37 +02:00
Girish Ramakrishnan dc07078fd4 set label for alias 2025-10-21 17:00:57 +02:00
Girish Ramakrishnan ae8278bdb3 Use dashboard domain as default and not [0] 2025-10-21 16:44:38 +02:00
Girish Ramakrishnan 286de8cdcb Update manifest format 2025-10-21 14:19:45 +02:00
Girish Ramakrishnan ca11d5af94 9.0.5 changes 2025-10-21 13:57:15 +02:00
Girish Ramakrishnan fb04f78112 backupcleaner: fix listing of backups by site 2025-10-21 13:56:08 +02:00
Girish Ramakrishnan 75fa2dfd67 remove unused import 2025-10-21 13:41:12 +02:00
Johannes Zellner 137267e604 Update pankow 2025-10-21 12:44:21 +02:00
Johannes Zellner 642487f4c5 Handle validitiy state in backup site adding dialog 2025-10-21 12:44:04 +02:00
Girish Ramakrishnan 783ad9ecda Fix hourly display 2025-10-21 11:11:40 +02:00
Johannes Zellner 0213a368b9 Use normal buttons for app start/stop 2025-10-21 10:10:26 +02:00
Girish Ramakrishnan f1e7594b79 Remove deleted users and groups in operators and access control
Fixes #857
2025-10-20 21:18:35 +02:00
Girish Ramakrishnan 02fd52e366 Remove any deleted group and user from operators and accessRestriction
part of #857
2025-10-20 16:51:23 +02:00
Girish Ramakrishnan 2d5e0a51bd add more to changelog 2025-10-20 15:23:57 +02:00
Johannes Zellner 1cd82dcd4c Revert old hetzner dns api file 2025-10-20 15:17:02 +02:00
Johannes Zellner 5ba30d0236 add hetznercloud DNS provider 2025-10-20 15:05:19 +02:00
Girish Ramakrishnan c0ea5c31eb Fix typo in app count 2025-10-20 15:03:15 +02:00
Johannes Zellner adee5fa25f Allow fonts loaded as inline data URI for the dashboard
Fixes #859
2025-10-20 15:01:16 +02:00
Girish Ramakrishnan f9af84fd85 9.0.4 changes 2025-10-20 14:58:44 +02:00
Girish Ramakrishnan 41cb381a2e backups: display the size and duration in info 2025-10-20 14:58:06 +02:00
Johannes Zellner 50ca07bfb8 login.signInAction is actually called login.loginAction 2025-10-20 14:53:57 +02:00
Girish Ramakrishnan 07732310c1 backuptask: track copy and upload statistics 2025-10-20 14:09:12 +02:00
Girish Ramakrishnan 854661e2d4 backuptask: print the upload statistics 2025-10-20 11:22:28 +02:00
Johannes Zellner 8cac83ed98 Add script to find and purge unused translations 2025-10-20 09:55:19 +02:00
Johannes Zellner 5ee8e9da80 Bring back filemanager translations 2025-10-20 09:53:49 +02:00
Johannes Zellner f5c81f5882 Use browser locales API to generate language labels 2025-10-20 09:04:29 +02:00
Girish Ramakrishnan a415b70adf Use marked.parseInline to not generate top level <p> 2025-10-18 11:00:46 +02:00
76 changed files with 2110 additions and 651 deletions
+25
View File
@@ -3011,3 +3011,28 @@
* Give domains list a larger max-height
* Make app error compatible with previous releases
[9.0.4]
* filemanager: fix missing translations
* display backup duration
* add hetznercloud DNS provider
[9.0.5]
* access control/operators: remove deleted users and groups
* backupcleaner: fix scoping of cleanup by site id
* Use normal buttons for app start/stop
* site schedule: Fix hourly display
[9.0.6]
* Autofocus search in appstore view
* All settings in sidebar should be same icon
* Make backup content list a TableView so we can sort it by size and fileCount
* Fix filemanager for custom apps
* Sort apps in the grid by label
* Filter dropdowns are searchable with more than 10 entries
* Show app icons in the grid in grayscale if app is stopped
* Support wildcard domain aliases in app location
[9.0.7]
* externalldap: only set group members if they changed
* Fix issue where backups remote paths were incorrectly migrated
+53
View File
@@ -0,0 +1,53 @@
## Translations
This documents the convention used for the text in the UI.
### Title Case
All words are capitalized.
In title case, articles (a/an/the) ,conjunctions (and/but/or/...) and prepositions (on/at/...)
inside a phrase are not capitalized.
Everything else is capitalized - noun, pronoun, verb, adverb.
Examples:
* "Sign In to Your Account"
* "Terms and Conditions"
* "Getting Started with GraphQL"
* "Between You and Me"
### Sentence case
Only first word is capitalized.
### Convention
| Element | Recommended Style | Example |
| -------------- | ---------------------- | -------------------------------- |
| Headings | Title Case | Manage Account |
| Sub heading | Title Case | Create Admin Account |
| Form Labels | Title Case | Email Address |
| Buttons | Sentence Case | Save changes |
| Radio Buttons | Sentence Case | Option one / Option two |
| Menu action | Sentence Case | Select all |
| Switches | Sentence Case | Allow users to edit email |
| Descriptions | Sentence case | Enter your password to continue. |
| Tooltips | Sentence case | Click to edit. |
| Error Messages | Sentence case | Password is too short |
| Notifications | Sentence case | Settings saved successfully. |
| Legend (graph) | Sentence case | Docker volume, Box data. |
Hints in brackets are small case. Like "(comma separated)".
## Full stops
Sentence fragments like form hints and tooltips (which are always visible) do not need a full stop.
All other full sentences do.
description has a full stop unless it's a hint/phrase.
Checkbox labels do not have a full stop at the end
+57 -57
View File
@@ -6,7 +6,7 @@
"packages": {
"": {
"dependencies": {
"@cloudron/pankow": "^3.5.3",
"@cloudron/pankow": "^3.5.6",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
"@vitejs/plugin-vue": "^6.0.1",
@@ -17,12 +17,12 @@
"async": "^3.2.6",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"eslint": "^9.38.0",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"marked": "^16.4.1",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"vite": "^7.1.10",
"vite": "^7.1.12",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12",
@@ -76,9 +76,9 @@
}
},
"node_modules/@cloudron/pankow": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.3.tgz",
"integrity": "sha512-7N9zHmCEfU86h2O6v8eNc34qrf0AGehf2b/5XlGJrpVsVw06HE+Ty/S/P22ECW+xqZXKu9pwXoal6S+UeQO6LQ==",
"version": "3.5.6",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.6.tgz",
"integrity": "sha512-6N10wpe6iO13fmBrnZ7KMrqRvmyh5e6qCkdj7jaR7ojatp5ozTDCvI1IwY4Nxpj/UWUUgXiYMlNgluWFhN4IsA==",
"license": "ISC",
"dependencies": {
"@fontsource/inter": "^5.2.8",
@@ -540,21 +540,21 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.16.0"
"@eslint/core": "^0.17.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
@@ -587,9 +587,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
"integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
"version": "9.39.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz",
"integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -608,12 +608,12 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.16.0",
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
},
"engines": {
@@ -1457,19 +1457,19 @@
}
},
"node_modules/eslint": {
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"version": "9.39.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz",
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.1",
"@eslint/core": "^0.16.0",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.38.0",
"@eslint/plugin-kit": "^0.4.0",
"@eslint/js": "9.39.0",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -2383,9 +2383,9 @@
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"node_modules/vite": {
"version": "7.1.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -2654,9 +2654,9 @@
}
},
"@cloudron/pankow": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.3.tgz",
"integrity": "sha512-7N9zHmCEfU86h2O6v8eNc34qrf0AGehf2b/5XlGJrpVsVw06HE+Ty/S/P22ECW+xqZXKu9pwXoal6S+UeQO6LQ==",
"version": "3.5.6",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.6.tgz",
"integrity": "sha512-6N10wpe6iO13fmBrnZ7KMrqRvmyh5e6qCkdj7jaR7ojatp5ozTDCvI1IwY4Nxpj/UWUUgXiYMlNgluWFhN4IsA==",
"requires": {
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
@@ -2845,17 +2845,17 @@
}
},
"@eslint/config-helpers": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"requires": {
"@eslint/core": "^0.16.0"
"@eslint/core": "^0.17.0"
}
},
"@eslint/core": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz",
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"requires": {
"@types/json-schema": "^7.0.15"
}
@@ -2877,9 +2877,9 @@
}
},
"@eslint/js": {
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
"integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A=="
"version": "9.39.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz",
"integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw=="
},
"@eslint/object-schema": {
"version": "2.1.7",
@@ -2887,11 +2887,11 @@
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="
},
"@eslint/plugin-kit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz",
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"requires": {
"@eslint/core": "^0.16.0",
"@eslint/core": "^0.17.0",
"levn": "^0.4.1"
}
},
@@ -3416,18 +3416,18 @@
}
},
"eslint": {
"version": "9.38.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"version": "9.39.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz",
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"requires": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.1",
"@eslint/config-helpers": "^0.4.1",
"@eslint/core": "^0.16.0",
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.38.0",
"@eslint/plugin-kit": "^0.4.0",
"@eslint/js": "9.39.0",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -4007,9 +4007,9 @@
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"vite": {
"version": "7.1.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"requires": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
+3 -3
View File
@@ -7,7 +7,7 @@
},
"type": "module",
"dependencies": {
"@cloudron/pankow": "^3.5.3",
"@cloudron/pankow": "^3.5.6",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
"@vitejs/plugin-vue": "^6.0.1",
@@ -18,12 +18,12 @@
"async": "^3.2.6",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"eslint": "^9.38.0",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"marked": "^16.4.1",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"vite": "^7.1.10",
"vite": "^7.1.12",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12",
+77 -4
View File
@@ -733,10 +733,14 @@
"reallyDelete": "Sletter du virkelig følgende?"
},
"newDirectoryDialog": {
"title": "Ny mappe"
"title": "Ny mappe",
"create": "Opret"
},
"renameDialog": {
"reallyOverwrite": "Der findes allerede en fil med det navn. Overskrive eksisterende fil?"
"reallyOverwrite": "Der findes allerede en fil med det navn. Overskrive eksisterende fil?",
"title": "Omdøb {{ fileName }}",
"newName": "Nyt navn",
"rename": "Omdøb"
},
"toolbar": {
"new": "Ny",
@@ -744,11 +748,80 @@
"newFile": "Ny fil",
"newFolder": "Ny mappe",
"uploadFile": "Upload fil",
"restartApp": "Genstart appen"
"restartApp": "Genstart appen",
"uploadFolder": "Upload mappe",
"openTerminal": "Åben terminal",
"openLogs": "Vis logs"
},
"extractionInProgress": "Udvinding i gang",
"pasteInProgress": "Indsætning i gang",
"deleteInProgress": "Sletning i gang"
"deleteInProgress": "Sletning i gang",
"chownDialog": {
"title": "Ændring af ejerskab",
"newOwner": "Ny ejer",
"change": "Skift ejer",
"recursiveCheckbox": "Ændre ejerskab rekursivt"
},
"uploadingDialog": {
"title": "Upload af filer ({{ countDone }}/{{{ count }})",
"errorAlreadyExists": "Der findes allerede en eller flere filer.",
"errorFailed": "Det lykkedes ikke at uploade en eller flere filer. Prøv venligst igen.",
"closeWarning": "Du må ikke opdatere siden, før upload er afsluttet.",
"retry": "Genoptag",
"overwrite": "Overskriv"
},
"extractDialog": {
"title": "Udpakning af {{ fileName }}",
"closeWarning": "Du må ikke opdatere siden, før udtrækket er færdigt."
},
"textEditorCloseDialog": {
"title": "Filen har ikke gemte ændringer",
"details": "Dine ændringer vil gå tabt, hvis du ikke gemmer dem",
"dontSave": "Spar ikke"
},
"notFound": "Ikke fundet",
"list": {
"name": "Navn",
"size": "Størrelse",
"owner": "Ejer",
"empty": "Ingen filer",
"symlink": "symlænk til {{ target }}",
"menu": {
"rename": "Omdøb",
"chown": "Ændring af ejerskab",
"extract": "Uddrag her",
"download": "Download",
"delete": "Slet",
"edit": "Rediger",
"cut": "Skær",
"copy": "Kopier",
"paste": "Indsæt",
"selectAll": "Vælg alle",
"open": "Åben"
},
"mtime": "Ændret"
},
"extract": {
"error": "Udtrækningen mislykkedes: {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Findes allerede"
},
"newFile": {
"errorAlreadyExists": "Findes allerede"
},
"status": {
"restartingApp": "genstart af app"
},
"uploader": {
"uploading": "Uploading",
"exitWarning": "Upload er stadig i gang. Skal vi virkelig lukke denne side?"
},
"textEditor": {
"undo": "Fortryd",
"redo": "Omarbejdning",
"save": "Gem"
}
},
"email": {
"incoming": {
+77 -4
View File
@@ -1031,13 +1031,17 @@
},
"title": "Datei-Manager",
"renameDialog": {
"reallyOverwrite": "Eine Datei mit diesem Namen existiert bereits. Diese Datei überschreiben?"
"reallyOverwrite": "Eine Datei mit diesem Namen existiert bereits. Diese Datei überschreiben?",
"title": "{{ fileName }} umbennen",
"newName": "Neuer Name",
"rename": "Umbenennen"
},
"removeDialog": {
"reallyDelete": "Wirklich löschen?"
},
"newDirectoryDialog": {
"title": "Neuer Ordner"
"title": "Neuer Ordner",
"create": "Erstellen"
},
"toolbar": {
"newFolder": "Neuer Ordner",
@@ -1045,11 +1049,80 @@
"upload": "Hochladen",
"newFile": "Neue Datei",
"uploadFile": "Datei hochladen",
"restartApp": "Anwendung neustarten"
"restartApp": "Anwendung neustarten",
"uploadFolder": "Ordner hochladen",
"openTerminal": "Terminal öffnen",
"openLogs": "Logfiles öffnen"
},
"extractionInProgress": "Entpacken läuft",
"pasteInProgress": "Einfügen läuft",
"deleteInProgress": "Löschen läuft"
"deleteInProgress": "Löschen läuft",
"chownDialog": {
"title": "Eigentümer ändern",
"newOwner": "Neuer Eigentümer",
"change": "Eigentümer ändern",
"recursiveCheckbox": "Eigentümer rekursiv ändern"
},
"uploadingDialog": {
"title": "Dateien hochladen ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "Eine oder mehrere Dateien sind bereits vorhanden.",
"errorFailed": "Das Hochladen einer oder mehrerer Dateien ist fehlgeschlagen. Bitte erneut versuchen.",
"closeWarning": "Die Seite nicht aktualisieren, bevor der Upload abgeschlossen ist.",
"retry": "Erneut versuchen",
"overwrite": "Überschreiben"
},
"extractDialog": {
"title": "Extrahieren von {{ fileName }}",
"closeWarning": "Die Seite nicht aktualisieren, bevor die Extraktion abgeschlossen ist."
},
"textEditorCloseDialog": {
"title": "Die Datei hat ungespeicherte Änderungen",
"details": "Änderungen gehen verloren, wenn sie nicht gespeichert werden",
"dontSave": "Nicht speichern"
},
"notFound": "Nicht gefunden",
"list": {
"name": "Name",
"size": "Größe",
"owner": "Besitzer*in",
"empty": "Keine Dateien",
"symlink": "Symlink zu {{ target }}",
"menu": {
"rename": "Umbenennen",
"chown": "Besitzverhältnis ändern",
"extract": "Hier auspacken",
"download": "Herunterladen",
"delete": "Löschen",
"edit": "Bearbeiten",
"cut": "Ausschneiden",
"copy": "Kopieren",
"paste": "Einfügen",
"selectAll": "Alles Auswählen",
"open": "Öffnen"
},
"mtime": "Geändert"
},
"extract": {
"error": "Auspacken gescheitert: {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Bereits vorhanden"
},
"newFile": {
"errorAlreadyExists": "Bereits vorhanden"
},
"status": {
"restartingApp": "Die Anwendung wird neugestartet"
},
"uploader": {
"uploading": "Hochladen",
"exitWarning": "Aktuell werden noch Dateien hochgeladen. Wirklich schließen?"
},
"textEditor": {
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"save": "Speichern"
}
},
"passwordReset": {
"usernameOrEmail": "Username oder E-Mail-Adresse",
+181 -101
View File
@@ -32,7 +32,7 @@
"done": "Done"
},
"username": "Username",
"displayName": "Display name",
"displayName": "Display Name",
"actions": "Actions",
"table": {
"date": "Date",
@@ -78,7 +78,7 @@
"location": "Location",
"locationPlaceholder": "Leave empty to use bare domain",
"manualWarning": "Manually set up A (IPv4) and AAAA (IPv6) DNS records for <b>{{ location }}</b> pointing to this server",
"userManagement": "User management",
"userManagement": "User Management",
"userManagementNone": "This app has its own user management. This setting determines whether this app is visible in the user's dashboard.",
"userManagementMailbox": "All users with a mailbox on this Cloudron have access.",
"userManagementLeaveToApp": "Leave user management to the app",
@@ -124,7 +124,8 @@
"settings": {
"allowProfileEditCheckbox": "Allow users to edit their name and email",
"require2FACheckbox": "Require users to set up 2FA",
"saveAction": "Save"
"saveAction": "Save",
"title": "Settings"
},
"externalLdap": {
"title": "Connect an External Directory",
@@ -132,7 +133,7 @@
"noopInfo": "LDAP authentication is not configured.",
"provider": "Provider",
"server": "Server URL",
"acceptSelfSignedCert": "Accept Self-signed certificate",
"acceptSelfSignedCert": "Accept Self-signed Certificate",
"baseDn": "Base DN",
"filter": "Filter",
"usernameField": "Username Field",
@@ -141,7 +142,7 @@
"groupFilter": "Group Filter",
"groupnameField": "Groupname Field",
"auth": "Auth",
"autocreateUsersOnLogin": "Automatically create users on login",
"autocreateUsersOnLogin": "Auto-create Users on Login",
"syncAction": "Sync",
"configureAction": "Configure",
"bindUsername": "Bind DN/Username (optional)",
@@ -151,25 +152,25 @@
},
"addUserDialog": {
"title": "Add User",
"sendInviteCheckbox": "Send an invitation email now",
"addUserAction": "Add User"
"sendInviteCheckbox": "Send Invitation Email",
"addUserAction": "Add"
},
"user": {
"fullName": "Full name",
"fullName": "Full Name",
"username": "Username",
"role": "Role",
"groups": "Groups",
"noGroups": "No groups available.",
"usernamePlaceholder": "Optional. If not provided, user can pick during sign up",
"displayName": "Display Name",
"primaryEmail": "Primary email",
"recoveryEmail": "Password recovery email",
"activeCheckbox": "User is active",
"primaryEmail": "Primary Email",
"recoveryEmail": "Password Recovery Email",
"activeCheckbox": "Active User",
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up",
"fallbackEmailPlaceholder": "If not specified, primary email will be used"
},
"deleteUserDialog": {
"title": "Delete user {{ username }}",
"title": "Delete User {{ username }}",
"description": "After deletion, the user will not be able to access the dashboard or login to any of the apps. Note that any user data inside the apps is not removed.",
"deleteAction": "Delete"
},
@@ -183,14 +184,15 @@
"group": {
"name": "Name",
"users": "Users",
"addGroupAction": "Add Group"
"addGroupAction": "Add",
"allowedApps": "Allowed Apps"
},
"editGroupDialog": {
"title": "Edit group {{ name }}",
"title": "Edit Group {{ name }}",
"externalLdapWarning": "This group is synced from the external LDAP directory."
},
"deleteGroupDialog": {
"title": "Delete group {{ name }}",
"title": "Delete Group {{ name }}",
"description": "This group has {{ memberCount }} member(s). Really remove this group?",
"deleteAction": "Delete"
},
@@ -220,10 +222,10 @@
"descriptionEmail": "Send invite link"
},
"setGhostDialog": {
"title": "Create password to impersonate {{ username }}",
"description": "Set a temporary password to login on behalf of this user in apps or the dashboard. This password is valid for 6 hours.",
"title": "Impersonate User {{ username }}",
"description": "Set a temporary password to log in on behalf of this user to apps or the dashboard. This password is valid for 6 hours.",
"password": "Temporary Password",
"setPassword": "Set Password",
"setPassword": "Set password",
"generatePassword": "Generate Password"
},
"invitationNotification": {
@@ -238,7 +240,7 @@
},
"secret": {
"label": "Bind Password",
"description": "All LDAP queries have to be authenticated with this secret and the user DN <i>{{ userDN }}</i>",
"description": "Authenticate queries with the user DN <i>{{ userDN }}</i> and this secret",
"url": "Server URL"
},
"cloudflarePortWarning": "Cloudflare proxying must be disabled on the dashboard domain to access the LDAP server",
@@ -250,14 +252,14 @@
},
"profile": {
"title": "Profile",
"primaryEmail": "Primary email",
"passwordRecoveryEmail": "Password recovery email",
"primaryEmail": "Primary Email",
"passwordRecoveryEmail": "Password Recovery Email",
"language": "Language",
"changePassword": {
"title": "Change password",
"currentPassword": "Current password",
"newPassword": "New password",
"newPasswordRepeat": "Repeat new password",
"title": "Change Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"newPasswordRepeat": "Repeat New Password",
"errorPasswordsDontMatch": "Passwords don't match"
},
"disable2FA": {
@@ -276,14 +278,14 @@
"title": "App Passwords",
"app": "App",
"name": "Name",
"noPasswordsPlaceholder": "No App Passwords created",
"noPasswordsPlaceholder": "No app passwords",
"description": "App passwords are a security measure to protect your Cloudron user account. If you need to access a Cloudron app from an untrusted mobile app or client, you can log in with your username and the alternate password generated here."
},
"apiTokens": {
"title": "API Tokens",
"name": "Name",
"description": "Use these personal access tokens to authenticate to the <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>",
"noTokensPlaceholder": "No API Tokens created",
"description": "Use these personal access tokens to authenticate to the <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>.",
"noTokensPlaceholder": "No API tokens",
"lastUsed": "Last Used",
"neverUsed": "never",
"scope": "Scope",
@@ -295,12 +297,12 @@
"loginTokens": {
"title": "Login Tokens",
"description": "You have {{ webadminTokenCount}} active web token(s) and {{ cliTokenCount }} CLI token(s).",
"logoutAll": "Logout From All"
"logoutAll": "Logout from all"
},
"changeEmail": {
"title": "Change primary email address",
"title": "Change Primary Email Address",
"email": "New Email Address",
"password": "Password for confirmation"
"password": "Confirm with Password"
},
"changeFallbackEmail": {
"title": "Change password recovery email address"
@@ -322,7 +324,7 @@
"access": "API Access",
"allowedIpRanges": "Allowed IP Range(s)"
},
"changePasswordAction": "Change Password",
"changePasswordAction": "Change password",
"disable2FAAction": "Disable 2FA",
"enable2FAAction": "Enable 2FA",
"passwordResetNotification": {
@@ -341,7 +343,7 @@
"remount": "Remount Storage"
},
"schedule": {
"title": "Schedule & Retention",
"title": "Schedule & retention",
"schedule": "Schedule",
"retentionPolicy": "Retention Policy"
},
@@ -352,8 +354,8 @@
"version": "Version",
"noApps": "No Apps",
"appCount": "{{ appCount }} App(s)",
"tooltipDownloadBackupConfig": "Download Config",
"cleanupBackups": "Cleanup Backups",
"tooltipDownloadBackupConfig": "Download config",
"cleanupBackups": "Cleanup backups",
"backupNow": "Backup now",
"tooltipPreservedBackup": "This backup will be preserved"
},
@@ -362,7 +364,9 @@
"id": "Id",
"date": "Date",
"version": "Version",
"list": "References backups of {{ appCount }} apps"
"list": "References backups of {{ appCount }} app(s)",
"size": "Size",
"duration": "Duration"
},
"configureBackupSchedule": {
"title": "Configure Backup Schedule and Retention",
@@ -380,7 +384,7 @@
"localDirectory": "Local backup directory",
"hardlinksLabel": "Use hardlinks",
"s3Endpoint": "Endpoint",
"acceptSelfSignedCerts": "Accept Self-signed certificate",
"acceptSelfSignedCerts": "Accept Self-signed Certificate",
"bucketName": "Bucket name",
"prefix": "Prefix",
"region": "Region",
@@ -482,7 +486,7 @@
"footer": {
"title": "Footer"
},
"backgroundImage": "Login page background image"
"backgroundImage": "Login Page Background"
},
"emails": {
"title": "Email",
@@ -490,14 +494,15 @@
"title": "Domains",
"outbound": "Outbound only",
"disabled": "Disabled",
"stats": "Count: {{ mailboxCount }} / Usage: {{ usage }}",
"testEmailTooltip": "Send Test Email"
"stats": "Mailboxes: {{ mailboxCount }} / Usage: {{ usage }}",
"testEmailTooltip": "Send test email",
"inbound": "Inbound & Outbound"
},
"settings": {
"title": "Settings",
"location": "Mail Server Location",
"maxMailSize": "Maximum email size",
"spamFilter": "Spam filtering",
"maxMailSize": "Maximum Email Size",
"spamFilter": "Spam Filtering",
"spamFilterOverview": "{{ blacklistCount }} address(es) on the blocklist.",
"solrFts": "Full Text Search",
"acl": "Mail ACL",
@@ -539,7 +544,7 @@
},
"spamFilterDialog": {
"title": "Spam Filtering",
"blacklisteAddresses": "Addresses on the blocklist",
"blacklisteAddresses": "Email Address Blocklist",
"blacklisteAddressesInfo": "Matched addresses will end up in the user's Spam folder. '*' and '?' glob patterns are supported.",
"customRules": "Custom Spamassassin Rules",
"blacklisteAddressesPlaceholder": "Line separated email address patterns",
@@ -610,7 +615,7 @@
"title": "Configure IPv6 Provider"
},
"trustedIps": {
"description": "HTTP headers from matching IP addresses will be trusted",
"description": "HTTP headers from matching IP addresses will be trusted.",
"title": "Configure Trusted IPs",
"summary": "{{ trustCount }} IPs trusted"
},
@@ -635,7 +640,7 @@
"appstoreAccount": {
"title": "Cloudron.io Account",
"description": "A Cloudron.io account is used to manage your subscription.",
"setupAction": "Set up Account",
"setupAction": "Set up account",
"subscription": "Subscription",
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Canceled and ends on",
@@ -655,12 +660,12 @@
},
"updates": {
"title": "Updates",
"checkForUpdatesAction": "Check for Updates",
"checkForUpdatesAction": "Check for updates",
"updateAvailableAction": "Update Available",
"stopUpdateAction": "Stop Update",
"disabled": "Disabled",
"schedule": "Schedule",
"description": "Platform and app updates are applied on the schedule set here, according to the <a href=\"/#/system-locale\">System Time Zone</a>.",
"description": "Platform and app updates are applied on the schedule set here, using the <a href=\"/#/system-settings\">System Time Zone</a>.",
"onLatest": "latest"
},
"updateScheduleDialog": {
@@ -738,19 +743,19 @@
"domain": "Domain",
"provider": "Provider",
"renewCerts": {
"title": "Renew certificates",
"title": "Renew Certificates",
"description": "Let's Encrypt certificates are renewed automatically. Use this option to trigger a renewal immediately.",
"renewAllAction": "Renew All Certs"
"renewAllAction": "Renew all certs"
},
"changeDashboardDomain": {
"title": "Dashboard Domain",
"description": "This will move the dashboard to the <code>my</code>subdomain of the selected domain.",
"changeAction": "Change Domain"
"changeAction": "Change domain"
},
"domainDialog": {
"addTitle": "Add Domain",
"editTitle": "Configure {{ domain }}",
"addDescription": "Adding a domain lets you install apps on subdomains of this domain. Email settings for the domain can be configured in the Email view.",
"addDescription": "Adding a domain lets you install apps on subdomains of this domain. Email settings can be configured in the Email view.",
"domain": "Domain",
"provider": "DNS Provider",
"route53AccessKeyId": "Access Key Id",
@@ -770,9 +775,9 @@
"namecheapUsername": "Namecheap Username",
"namecheapApiKey": "API Key",
"namecheapInfo": "The servers IP address must be added to the allowlist for this API key",
"manualInfo": "All DNS records have to be set up manually before each app installation.",
"manualInfo": "All DNS records must be set up manually before installing an app",
"wildcardInfo": "Manually set up A (IPv4) and AAAA (IPv6) DNS records for <b>*.{{ domain }}.</b> and <b>{{ domain }}.</b> pointing to this server",
"letsEncryptInfo": "Let's Encrypt requires your server to be reachable on port 80",
"letsEncryptInfo": "Let's Encrypt requires your server to be reachable on port 80.",
"advancedAction": "Advanced settings…",
"zoneName": "Zone Name (Optional)",
"fallbackCert": "Fallback Certificate (optional)",
@@ -789,7 +794,7 @@
"wellKnownDescription": "The values will be used to respond to <code>https://{{ domain }}/.well-known/</code> URLs. Note that an app must be available on the bare domain <code>{{ domain }}</code> for this to work. See the <a href=\"{{docsLink}}\" target=\"_blank\">docs</a> for more information.",
"jitsiHostname": "Jitsi Location",
"hetznerToken": "Hetzner Token",
"cloudflareDefaultProxyStatus": "Enable proxying for new DNS records",
"cloudflareDefaultProxyStatus": "Enable Proxying for New DNS Records",
"porkbunApikey": "API Key",
"porkbunSecretapikey": "Secret API Key",
"bunnyAccessKey": "Bunny Access Key",
@@ -804,7 +809,7 @@
"gandiTokenTypePAT": "Personal Access Token (PAT)",
"inwxUsername": "Username",
"inwxPassword": "Password",
"customNameservers": "Domain uses custom (vanity) nameservers"
"customNameservers": "Domain Uses Custom (Vanity) Nameservers"
},
"removeDialog": {
"title": "Really remove {{ domain }}?",
@@ -818,13 +823,13 @@
"domainWellKnown": {
"title": "Well-Known locations of {{ domain }}"
},
"tooltipWellKnown": "Well-Known Locations",
"tooltipWellKnown": "Well-Known locations",
"emptyPlaceholder": "No Domains",
"noMatchesPlaceholder": "No matching domain"
},
"notifications": {
"dismissTooltip": "Dismiss",
"markAllAsRead": "Mark All as Read",
"markAllAsRead": "Mark all as read",
"settings": {
"title": "Notification Settings",
"backupFailed": "Backup failed",
@@ -833,7 +838,7 @@
"appUp": "App is back online",
"appDown": "App is down",
"rebootRequired": "Server reboot required",
"cloudronUpdateFailed": "Cloudron Update Failed",
"cloudronUpdateFailed": "Cloudron update failed",
"diskSpace": "Low disk space"
},
"settingsDialog": {
@@ -843,8 +848,8 @@
},
"logs": {
"title": "Logs",
"clear": "Clear View",
"download": "Download Full Logs"
"clear": "Clear view",
"download": "Download full logs"
},
"terminal": {
"title": "Terminal",
@@ -861,30 +866,103 @@
"reallyDelete": "Really delete?"
},
"newDirectoryDialog": {
"title": "New Folder"
"title": "New Folder",
"create": "Create"
},
"newFileDialog": {
"title": "New File",
"create": "Create"
},
"renameDialog": {
"reallyOverwrite": "A file with that name already exists. Overwrite existing file?"
"reallyOverwrite": "A file with that name already exists. Overwrite existing file?",
"title": "Rename {{ fileName }}",
"newName": "New Name",
"rename": "Rename"
},
"toolbar": {
"new": "New",
"upload": "Upload",
"newFile": "New File",
"newFolder": "New Folder",
"uploadFile": "Upload File",
"restartApp": "Restart App"
"newFile": "New file",
"newFolder": "New folder",
"uploadFile": "Upload file",
"restartApp": "Restart App",
"uploadFolder": "Upload folder",
"openTerminal": "Open Terminal",
"openLogs": "Open Logs"
},
"extractionInProgress": "Extraction in progress",
"pasteInProgress": "Pasting in progress",
"deleteInProgress": "Deletion in progress"
"deleteInProgress": "Deletion in progress",
"chownDialog": {
"title": "Change ownership",
"newOwner": "New Owner",
"change": "Change Owner",
"recursiveCheckbox": "Change ownership recursively"
},
"uploadingDialog": {
"title": "Uploading files ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "One or more files already exist.",
"errorFailed": "Failed to upload one or more files. Please try again.",
"closeWarning": "Do not refresh the page until upload has finished.",
"retry": "Retry",
"overwrite": "Overwrite"
},
"extractDialog": {
"title": "Extracting {{ fileName }}",
"closeWarning": "Do not refresh the page until extract has finished."
},
"textEditorCloseDialog": {
"title": "File has unsaved changes",
"details": "Your changes will be lost if you don't save them",
"dontSave": "Don't Save"
},
"notFound": "Not found",
"list": {
"name": "Name",
"size": "Size",
"owner": "Owner",
"empty": "No files",
"symlink": "symlink to {{ target }}",
"menu": {
"rename": "Rename",
"chown": "Change ownership",
"extract": "Extract Here",
"download": "Download",
"delete": "Delete",
"edit": "Edit",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"selectAll": "Select All",
"open": "Open"
},
"mtime": "Modified"
},
"extract": {
"error": "Failed to extract: {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Already exists"
},
"newFile": {
"errorAlreadyExists": "Already exists"
},
"status": {
"restartingApp": "restarting app"
},
"uploader": {
"uploading": "Uploading",
"exitWarning": "Upload still in progress. Really close this page?"
},
"textEditor": {
"undo": "Undo",
"redo": "Redo",
"save": "Save"
}
},
"email": {
"config": {
"title": "Email configuration {{ domain }}",
"title": "Email Configuration {{ domain }}",
"clientConfiguration": "Configuring Email Clients",
"sending": {
"title": "Sending"
@@ -907,7 +985,8 @@
"aliases": "Aliases",
"usage": "Usage",
"emptyPlaceholder": "No Mailboxes",
"noMatchesPlaceholder": "No matching mailboxes"
"noMatchesPlaceholder": "No matching mailboxes",
"stats": "Count: {{ mailboxCount }} / Usage: {{ usage }}"
},
"mailinglists": {
"title": "Mailing Lists",
@@ -915,7 +994,7 @@
"members": "List Members",
"everyoneTooltip": "Posting allowed by non-members",
"membersOnlyTooltip": "Posting restricted to members only",
"emptyPlaceholder": "No Mailing Lists",
"emptyPlaceholder": "No mailing lists",
"noMatchesPlaceholder": "No matching mailing lists"
},
"catchall": {
@@ -938,7 +1017,7 @@
"mailRelay": {
"host": "SMTP Host",
"port": "SMTP Port (STARTTLS)",
"selfsignedCheckbox": "Accept Self-signed certificate",
"selfsignedCheckbox": "Accept Self-signed Certificate",
"apiTokenOrKey": "API Token/Key",
"username": "Username",
"password": "Password",
@@ -950,10 +1029,10 @@
"description": "Masquerading allows users and apps to send emails with an arbitrary username in the FROM address."
},
"signature": {
"title": "Signature",
"title": "Email Signature",
"description": "The text here will be attached to all emails going out from this domain.",
"plainTextFormat": "Text format",
"htmlFormat": "HTML format"
"plainTextFormat": "Text Format",
"htmlFormat": "HTML Format"
},
"dnsStatus": {
"description": "Status of DNS Records may show an error while DNS is propagating (~5 minutes). See the <a href=\"{{ emailDnsDocsLink }}\" target=\"_blank\">troubleshooting</a> docs for help.",
@@ -992,40 +1071,40 @@
"incomingDisabledWarning": "Incoming email for this domain is not enabled."
},
"editMailboxDialog": {
"title": "Edit mailbox {{ name }}@{{ domain }}",
"title": "Edit Mailbox {{ name }}@{{ domain }}",
"owner": "Mailbox Owner",
"aliases": "Aliases",
"noAliases": "No aliases are configured.",
"addAliasAction": "Add an alias",
"addAnotherAliasAction": "Add another alias",
"enableStorageQuota": "Enable Storage Quota"
"enableStorageQuota": "Storage Quota"
},
"deleteMailboxDialog": {
"title": "Delete mailbox {{ name }}@{{ domain }}",
"description": "After deletion, emails to this mailbox will bounce. You can choose to not delete emails in this mailbox for archival purposes. Archived emails are located at <code>/home/yellowtent/boxdata/mail/vmail</code> on the server.",
"purgeMailboxCheckbox": "Delete all mails and filters inside this mailbox",
"purgeMailboxCheckbox": "Delete All Mail and Filters in This Mailbox",
"deleteAction": "Delete"
},
"addMailinglistDialog": {
"title": "Add Mailing list",
"members": "List Members",
"membersOnlyCheckbox": "Restrict posting to members only",
"membersOnlyCheckbox": "Restrict Posting to List Members",
"name": "Name"
},
"editMailinglistDialog": {
"title": "Edit Mailing list {{ name }}@{{ domain }}"
"title": "Edit Mailing List {{ name }}@{{ domain }}"
},
"deleteMailinglistDialog": {
"title": "Delete mailing list {{ name }}@{{ domain }}",
"title": "Delete Mailing List {{ name }}@{{ domain }}",
"description": "Really delete mailinglist <b>{{ name }}@{{ domain }}</b>?",
"deleteAction": "Delete"
},
"updateMailinglistDialog": {
"activeCheckbox": "Mailing list is active"
"activeCheckbox": "Active Mailing List"
},
"updateMailboxDialog": {
"activeCheckbox": "Mailbox is active",
"enablePop3": "Enable POP3 access"
"activeCheckbox": "Active Mailbox",
"enablePop3": "POP3 Access"
},
"howToConnectInfoModal": "Configuring Email Clients"
},
@@ -1074,7 +1153,7 @@
"userManagement": {
"description": "This app is configured to authenticate with the Cloudron User Directory. This setting controls who can log in and use the app.",
"descriptionSftp": "This setting also controls SFTP access.",
"dashboardVisibility": "Dashboard visibility",
"dashboardVisibility": "Dashboard Visibility",
"visibleForAllUsers": "Visible to all users on this Cloudron",
"visibleForSelected": "Only visible to the following users and groups"
},
@@ -1108,11 +1187,11 @@
"appdata": {
"title": "Data Directory",
"description": "If the server is running out of disk space, use this to move the app's data to a <a href=\"/#/volumes\">volume</a>. Any data here is part of the app's backup.",
"moveAction": "Move Data",
"moveAction": "Move data",
"mountTypeWarning": "The destination file system must support file permissions and ownership for the move to work"
},
"mounts": {
"title": "Volume mounts",
"title": "Volume Mounts",
"volume": "Volume",
"noMounts": "No volumes are mounted.",
"addMountAction": "Add a volume mount",
@@ -1134,8 +1213,8 @@
"live": "Live",
"1h": "1 hour"
},
"diskIOTotal": "Total Read: {{ read }} Total Write: {{ write }}",
"networkIOTotal": "Total Inbound: {{ inbound }} Total Outbound: {{ outbound }}"
"diskIOTotal": "Total read: {{ read }} Total write: {{ write }}",
"networkIOTotal": "Total inbound: {{ inbound }} Total outbound: {{ outbound }}"
},
"email": {
"from": {
@@ -1167,7 +1246,7 @@
"txtPlaceholder": "Leave empty to allow all bots to index this app",
"disableIndexingAction": "Disable indexing"
},
"hstsPreload": "Enable HSTS preload for this site and all subdomains"
"hstsPreload": "Enable HSTS Preload (including subdomains)"
},
"updates": {
"info": {
@@ -1191,11 +1270,11 @@
"title": "Backups",
"description": "Backups are complete snapshots of the app. You can use app backups to restore or clone this app.",
"time": "Created At",
"downloadConfigTooltip": "Download Config",
"downloadConfigTooltip": "Download config",
"cloneTooltip": "Clone",
"restoreTooltip": "Restore",
"createBackupAction": "Create Backup",
"importAction": "Import Backup",
"createBackupAction": "Create backup",
"importAction": "Import backup",
"downloadBackupTooltip": "Download",
"checkIntegrity": "Check Integrity"
},
@@ -1214,12 +1293,12 @@
"description": "To fix broken plugins or misconfiguration, place the app in Recovery Mode.",
"restartAction": "Restart",
"disableAction": "Disable Recovery Mode",
"enableAction": "Enable Recovery Mode"
"enableAction": "Enable recovery mode"
},
"taskError": {
"title": "Task Error",
"description": "If an installation, configuration, update, restore or backup action resulted in an error, you can retry the task.",
"retryAction": "Retry {{ task }} Task"
"retryAction": "Retry {{ task }} task"
},
"restart": {
"title": "Restart",
@@ -1440,7 +1519,7 @@
"editVolumeDialog": {
"title": "Edit volume {{ name }}"
},
"emptyPlaceholder": "No Volumes"
"emptyPlaceholder": "No volumes"
},
"newLoginEmail": {
"subject": "[<%= cloudron %>] New login on your account",
@@ -1464,7 +1543,7 @@
"id": "Client ID",
"secret": "Client Secret",
"signingAlgorithm": "Signing Algorithm",
"loginRedirectUri": "Login callback URLs (comma separated)"
"loginRedirectUri": "Login Callback URLs (comma separated)"
},
"description": "The OpenID provider can be used by external applications for single sign-on.",
"editClientDialog": {
@@ -1479,7 +1558,7 @@
},
"clients": {
"title": "OpenID Clients",
"empty": "No OpenID Clients"
"empty": "No OpenID clients"
}
},
"userdirectory": {
@@ -1489,13 +1568,14 @@
},
"archives": {
"listing": {
"placeholder": "No Archived Apps"
"placeholder": "No archived apps"
}
},
"backup": {
"target": {
"label": "Backup Site",
"size": "Size"
"size": "Size",
"fileCount": "Files"
},
"sites": {
"title": "Backup Sites",
@@ -1523,7 +1603,7 @@
"dialog": {
"title": "Docker Registry"
},
"emptyPlaceholder": "No Docker Registries"
"emptyPlaceholder": "No docker registries"
},
"dockerRegistres": {
"removeDialog": {
@@ -1538,7 +1618,7 @@
},
"externallinks": {
"label": "External Links",
"description": "Add shortcuts to external services on the dashboard"
"description": "Add shortcuts to external services on the dashboard."
},
"server": {
"title": "Server"
File diff suppressed because it is too large Load Diff
+78 -5
View File
@@ -1149,25 +1149,98 @@
"newFolder": "Nouveau dossier",
"newFile": "Nouveau fichier",
"upload": "Charger",
"new": "Nouveau"
"new": "Nouveau",
"uploadFolder": "Charger un dossier",
"openTerminal": "Ouvrir le terminal",
"openLogs": "Afficher les journaux"
},
"renameDialog": {
"reallyOverwrite": "Un fichier portant ce nom existe déjà. Écraser le fichier existant?"
"reallyOverwrite": "Un fichier portant ce nom existe déjà. Écraser le fichier existant?",
"title": "Renommer {{ fileName }}",
"newName": "Nouveau nom",
"rename": "Renommer"
},
"newFileDialog": {
"create": "Créer",
"title": "Nouveau fichier"
},
"newDirectoryDialog": {
"title": "Nouveau dossier"
"title": "Nouveau dossier",
"create": "Créer"
},
"removeDialog": {
"reallyDelete": "Voulez-vous vraiment supprimer ces fichiers ?"
"reallyDelete": "Voulez-vous vraiment supprimer ces fichiers?"
},
"title": "Gestionnaire de fichiers",
"deleteInProgress": "Suppression en cours",
"extractionInProgress": "Décompression en cours",
"pasteInProgress": "Collage en cours"
"pasteInProgress": "Collage en cours",
"chownDialog": {
"title": "Modifier la propriété",
"newOwner": "Nouveau propriétaire",
"change": "Modifier le propriétaire",
"recursiveCheckbox": "Modifier récursivement la propriété"
},
"uploadingDialog": {
"title": "Téléchargement des fichiers ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "Un ou plusieurs fichiers existent déjà.",
"errorFailed": "Impossible de charger un ou plusieurs fichiers. Essayez à nouveau.",
"closeWarning": "Ne rafraîchissez pas la page avant la fin du chargement.",
"retry": "Réessayer",
"overwrite": "Écraser"
},
"extractDialog": {
"title": "Extraction en cours... {{ fileName }}",
"closeWarning": "Ne rafraîchissez pas la page avant la fin de l'extraction."
},
"textEditorCloseDialog": {
"title": "Le fichier comporte des modifications non sauvegardées",
"details": "Vos modifications seront perdues si vous ne les sauvegardez pas",
"dontSave": "Ne pas sauvegarder"
},
"notFound": "Non trouvé",
"list": {
"name": "Nom",
"size": "Taille",
"owner": "Propriétaire",
"empty": "Aucun fichier",
"symlink": "Symlink vers {{ target }}",
"menu": {
"rename": "Renommer",
"chown": "Modifier la propriété",
"extract": "Extraire ici",
"download": "Télécharger",
"delete": "Supprimer",
"edit": "Modifier",
"cut": "Couper",
"copy": "Copier",
"paste": "Coller",
"selectAll": "Tout sélectionner",
"open": "Ouvrir"
},
"mtime": "Modifié"
},
"extract": {
"error": "L'extraction a échoué : {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Le dossier existe déjà"
},
"newFile": {
"errorAlreadyExists": "Le fichier existe déjà"
},
"status": {
"restartingApp": "Redémarrage de l'application..."
},
"uploader": {
"uploading": "Téléversement",
"exitWarning": "Téléversement toujours en cours. Voulez-vous vraiment fermer cette page?"
},
"textEditor": {
"undo": "Annuler",
"redo": "Refaire",
"save": "Enregistrer"
}
},
"terminal": {
"downloadAction": "Télécharger",
+68 -3
View File
@@ -436,19 +436,84 @@
"newFolder": "Nuova cartella",
"newFile": "Nuovo documento",
"upload": "Carica",
"new": "Nuovo"
"new": "Nuovo",
"uploadFolder": "Carica cartella",
"openTerminal": "Apri il terminale",
"openLogs": "Vedi i logs"
},
"newFileDialog": {
"create": "Crea",
"title": "Nuovo documento"
},
"newDirectoryDialog": {
"title": "Nuova cartella"
"title": "Nuova cartella",
"create": "Crea"
},
"removeDialog": {
"reallyDelete": "Eliminare davvero quanto segue?"
},
"title": "File Manager"
"title": "File Manager",
"renameDialog": {
"title": "Rinomina {{ fileName }}",
"newName": "Nuovo nome",
"rename": "Rinomina"
},
"chownDialog": {
"title": "Cambia proprietà",
"newOwner": "Nuovo proprietario",
"change": "Cambia proprietario",
"recursiveCheckbox": "Cambia proprietario (ricorsivo)"
},
"uploadingDialog": {
"title": "Carico documenti in corso ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "Uno o più documenti sono già esistenti.",
"errorFailed": "Impossibile caricare uno o più file. Per favore riprova.",
"closeWarning": "Non aggiornare la pagina fino al termine del caricamento.",
"retry": "Riprova",
"overwrite": "Sovrascrivi"
},
"extractDialog": {
"title": "Estraggo {{ fileName }}",
"closeWarning": "Non aggiornare la pagina fino al termine dell'estrazione."
},
"textEditorCloseDialog": {
"title": "Il file ha dei cambiamenti non salvati",
"details": "I cambiamenti verranno persi se non salvi documento prima di chiudere",
"dontSave": "Non salvare"
},
"notFound": "Non trovato",
"list": {
"name": "Nome",
"size": "Dimensione",
"owner": "Proprietario",
"empty": "Non ci sono documenti",
"symlink": "symlink a {{ target }}",
"menu": {
"rename": "Rinomina",
"chown": "Cambia proprietario",
"extract": "Estrai qui",
"download": "Scarica",
"delete": "Cancella",
"edit": "Modifica",
"cut": "Taglia",
"copy": "Copia",
"paste": "Incolla",
"selectAll": "Seleziona Tutto"
},
"mtime": "Modificato"
},
"extract": {
"error": "Errore nell'estrazione: {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Esiste già"
},
"newFile": {
"errorAlreadyExists": "Già esistente"
},
"status": {
"restartingApp": "riavviando l'app"
}
},
"backups": {
"configureBackupStorage": {
+160 -79
View File
@@ -31,7 +31,7 @@
"done": "Klaar"
},
"username": "Gebruikersnaam",
"displayName": "Naam",
"displayName": "Weergavenaam",
"actions": "Acties",
"table": {
"date": "Datum",
@@ -124,13 +124,14 @@
"settings": {
"require2FACheckbox": "Gebruikers moeten 2FA activeren",
"saveAction": "Opslaan",
"allowProfileEditCheckbox": "Sta gebruikers toe om hun naam en e-mail aan te passen"
"allowProfileEditCheckbox": "Sta gebruikers toe om hun naam en e-mail aan te passen",
"title": "Instellingen"
},
"externalLdap": {
"title": "Verbind met een externe lijst",
"noopInfo": "LDAP authenticatie is niet geconfigureerd.",
"provider": "Aanbieder",
"acceptSelfSignedCert": "Accepteer zelf-ondertekende certificaten",
"acceptSelfSignedCert": "Accepteer zelf-ondertekend Certificaat",
"baseDn": "Base DN",
"filter": "Filter",
"usernameField": "Veld voor gebruikersnaam",
@@ -146,31 +147,31 @@
"errorSelfSignedCert": "Server gebruikt een ongeldig of zelf-ondertekend certificaat.",
"description": "Deze instelling synchroniseert en authenticeert gebruikers en groepen van een extern LDAP of Active Directory server. De synchronisatie is periodiek maar kan ook handmatig gestart worden.",
"auth": "Authenticatie",
"autocreateUsersOnLogin": "Maak automatisch gebruikers bij inloggen",
"autocreateUsersOnLogin": "Automatisch gebruikers aanmaken bij het inloggen",
"disableWarning": "De authentificatie-bron van alle bestaande gebruikers zal worden omgezet naar authentificatie via de lokale wachtwoord database."
},
"addUserDialog": {
"addUserAction": "Gebruiker toevoegen",
"addUserAction": "Toevoegen",
"title": "Gebruiker toevoegen",
"sendInviteCheckbox": "Stuur nu een uitnodigingsmail"
"sendInviteCheckbox": "Uitnodigingsmail verzenden"
},
"user": {
"fullName": "Volledige naam",
"fullName": "Volledige Naam",
"username": "Gebruikersnaam",
"role": "Rol",
"groups": "Groepen",
"noGroups": "Geen groepen beschikbaar.",
"displayName": "Weergavenaam",
"primaryEmail": "Primair e-mailadres",
"recoveryEmail": "E-mailadres voor wachtwoordherstel",
"activeCheckbox": "Gebruiker is actief",
"primaryEmail": "Primair E-mailadres",
"recoveryEmail": "Wachtwoordherstel e-mailadres",
"activeCheckbox": "Actieve Gebruiker",
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
"fallbackEmailPlaceholder": "Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding"
},
"deleteUserDialog": {
"deleteAction": "Verwijder",
"title": "Verwijder gebruiker {{ username }}",
"title": "Verwijder Gebruiker {{ username }}",
"description": "Na verwijdering heeft de gebruiker geen toegang meer tot het Dashboard of apps. Let op: de gebruikersgegevens in de apps worden niet verwijderd."
},
"editUserDialog": {
@@ -183,7 +184,8 @@
"group": {
"name": "Naam",
"users": "Gebruikers",
"addGroupAction": "Groep toevoegen"
"addGroupAction": "Toevoegen",
"allowedApps": "Toegestane Apps"
},
"editGroupDialog": {
"title": "Groep {{ name }} bewerken",
@@ -220,8 +222,8 @@
"descriptionEmail": "Stuur uitnodigingslink"
},
"setGhostDialog": {
"description": "Stel een tijdelijk wachtwoord in namens deze gebruiker in apps of het Dashboard. Dit wachtwoord is 6 uur geldig.",
"title": "Maak een wachtwoord om {{ username }} na te bootsen",
"description": "Stel een tijdelijk wachtwoord in om namens deze gebruiker in te loggen bij apps of het dashboard. Dit wachtwoord is 6 uur geldig.",
"title": "Impersoneren als gebruiker {{ username }}",
"password": "Tijdelijk Wachtwoord",
"setPassword": "Wachtwoord instellen",
"generatePassword": "Genereer wachtwoord"
@@ -238,7 +240,7 @@
"description": "De LDAP server laat externe applicaties gebruikers authenticeren met de Cloudron gebruikerslijst.",
"secret": {
"label": "Koppel wachtwoord",
"description": "Alle LDAP verzoeken moeten geauthentiseerd worden met dit geheim en de gebruiker DN <i>{{ userDN }}</i>",
"description": "Authenticeer queries met de DN van de gebruiker <i>{{ userDN }}</i> en dit geheim",
"url": "Server URL"
},
"cloudflarePortWarning": "Cloudflare proxy moet uitgeschakeld zijn op het domein van het dashboard om de LDAP server te kunnen bereiken",
@@ -254,11 +256,11 @@
"passwordRecoveryEmail": "Wachtwoordherstel e-mailadres",
"language": "Taal",
"changePassword": {
"title": "Verander wachtwoord",
"newPassword": "Nieuw wachtwoord",
"newPasswordRepeat": "Herhaal nieuw wachtwoord",
"title": "Verander Wachtwoord",
"newPassword": "Nieuw Wachtwoord",
"newPasswordRepeat": "Herhaal Nieuw Wachtwoord",
"errorPasswordsDontMatch": "Wachtwoorden komen niet overeen",
"currentPassword": "Huidig wachtwoord"
"currentPassword": "Huidig Wachtwoord"
},
"disable2FA": {
"password": "Wachtwoord",
@@ -275,15 +277,15 @@
"appPasswords": {
"app": "App",
"name": "Naam",
"noPasswordsPlaceholder": "Er zijn geen App wachtwoorden aangemaakt",
"noPasswordsPlaceholder": "Geen app-wachtwoorden",
"title": "App wachtwoorden",
"description": "App wachtwoorden zijn een veiligheidsmiddel om je Cloudronaccount te beschermen. Indien je toegang wilt tot een Cloudron-app met een niet-vertrouwde mobiele app of andere software, kun je inloggen met je gebruikersnaam en app wachtwoord die je hier kunt aanmaken."
},
"apiTokens": {
"title": "API Tokens",
"name": "Naam",
"noTokensPlaceholder": "Er zijn geen API Tokens aangemaakt",
"description": "Gebruik deze persoonlijke toegangstokens voor authenticatie met de <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>",
"noTokensPlaceholder": "Geen API-tokens",
"description": "Gebruik deze persoonlijke toegangstokens voor authenticatie met de <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>.",
"neverUsed": "nooit",
"lastUsed": "Laatst gebruikt",
"scope": "Bereik",
@@ -300,7 +302,7 @@
"changeEmail": {
"title": "Primair e-mailadres aanpassen",
"email": "Nieuw e-mailadres",
"password": "Wachtwoord ter bevestiging"
"password": "Bevestig met wachtwoord"
},
"changeFallbackEmail": {
"title": "E-mailadres voor wachtwoordherstel wijzigen"
@@ -322,7 +324,7 @@
"access": "API toegang",
"allowedIpRanges": "Toegestane IP Range(s)"
},
"changePasswordAction": "Verander wachtwoord",
"changePasswordAction": "Verander Wachtwoord",
"disable2FAAction": "Twee-Factor (2FA) authenticatie uitschakelen",
"enable2FAAction": "Twee-Factor (2FA) authenticatie inschakelen",
"passwordResetNotification": {
@@ -341,7 +343,7 @@
"remount": "Her-koppel Storage"
},
"schedule": {
"title": "Planning & Bewaartermijn",
"title": "Planning & bewaartermijn",
"schedule": "Planning",
"retentionPolicy": "Bewaartermijn"
},
@@ -354,7 +356,7 @@
"cleanupBackups": "Backups opschonen",
"backupNow": "Backup maken",
"appCount": "{{ appCount }} App(s)",
"tooltipDownloadBackupConfig": "Download Configuratie",
"tooltipDownloadBackupConfig": "Download configuratie",
"tooltipPreservedBackup": "Deze backup blijft behouden"
},
"backupDetails": {
@@ -362,7 +364,9 @@
"id": "Id",
"date": "Datum",
"version": "Versie",
"list": "Bevat backups van {{appCount}} apps"
"list": "Verwijst naar backups van {{ appCount }} app(s)",
"size": "Grootte",
"duration": "Duur"
},
"configureBackupSchedule": {
"title": "Configureer Backup Planning en Bewaartermijn",
@@ -380,7 +384,7 @@
"localDirectory": "Lokale backup map",
"hardlinksLabel": "Gebruik hardlinks",
"s3Endpoint": "Eindpunt",
"acceptSelfSignedCerts": "Accepteer zelf-ondertekend certificaat",
"acceptSelfSignedCerts": "Accepteer zelf-ondertekend Certificaat",
"bucketName": "Bucket naam",
"prefix": "Prefix",
"region": "Regio",
@@ -482,7 +486,7 @@
"footer": {
"title": "Voettekst"
},
"backgroundImage": "Inlogpagina achtergrond afbeelding"
"backgroundImage": "Inlogpagina Achtergrond"
},
"emails": {
"title": "E-mail",
@@ -491,12 +495,13 @@
"testEmailTooltip": "Verstuur test e-mail",
"outbound": "Alleen uitgaand",
"disabled": "Uitgeschakeld",
"stats": "Aantal: {{ mailboxCount }} / Opslaggebruik: {{ usage }}"
"stats": "Mailboxen: {{ mailboxCount }} / Opslaggebruik: {{ usage }}",
"inbound": "Inkomend en Uitgaand"
},
"settings": {
"title": "Instellingen",
"location": "Mail Server Locatie",
"maxMailSize": "Maximale e-mail grootte",
"maxMailSize": "Maximale e-mailgrootte",
"spamFilter": "Spam filtering",
"spamFilterOverview": "{{ blacklistCount }} adres(sen) op de blokkeerlijst.",
"solrFts": "Zoek volledige tekst",
@@ -539,7 +544,7 @@
},
"spamFilterDialog": {
"title": "Spam Filtering",
"blacklisteAddresses": "E-mailadressen op de blokkeer-lijst",
"blacklisteAddresses": "Blokkeerlijst voor e-mailadressen",
"customRules": "Aangepaste Spamassassin regels",
"blacklisteAddressesPlaceholder": "Regel gescheiden e-mailadres patronen",
"customRulesPlaceholder": "Aangepaste Spamassassin regels",
@@ -589,7 +594,7 @@
"nameComApiToken": "API Token",
"namecheapUsername": "Namecheap Gebruikersnaam",
"namecheapApiKey": "API Sleutel",
"manualInfo": "Alle DNS records moeten handmatig ingesteld worden bij elke installatie voor elke app.",
"manualInfo": "Alle DNS-records moeten handmatig worden aangemaakt voordat een app geïnstalleerd kan worden.",
"wildcardInfo": "Stel handmatig A (IPv4) and AAAA (IPv6) DNS records in voor <b>*.{{ domain }}</b> en <b>{{ domain }}</b> met verwijzingen naar deze Cloudron server",
"advancedAction": "Geavanceerde instellingen …",
"zoneName": "Zone Naam (Optioneel)",
@@ -598,8 +603,8 @@
"fallbackCertCustomCertInfo": "Dit <a href=\"{{ customCertLink }}\" target=\"_blank\">wildcardcertificaat</a> wordt gebruikt voor alle apps van dit domein. Als dit niet het geval is, wordt een automatisch gegenereerd zelfondertekend certificaat gebruikt.",
"fallbackCertKeyPlaceholder": "Sleutel",
"fallbackCertCertificatePlaceholder": "Certificaat",
"letsEncryptInfo": "Let's Encrypt vereist dat deze server bereikbaar is op poort 80",
"addDescription": "Met het toevoegen van een domein krijg je de mogelijkheid om apps te installeren op subdomeinen. E-mailinstellingen voor het domein gaat via het E-mail scherm.",
"letsEncryptInfo": "Let's Encrypt vereist dat deze server bereikbaar is op poort 80.",
"addDescription": "Een domein toevoegen maakt het mogelijk om apps te installeren op subdomeinen van dit domein. E-mailinstellingen kunnen in het E-mailscherm worden geconfigureerd.",
"domain": "Domein",
"gcdnsServiceAccountKey": "Service Account Sleutel",
"mastodonHostname": "Mastodon Server Locatie",
@@ -611,7 +616,7 @@
"jitsiHostname": "Jitsi Locatie",
"wellKnownDescription": "De waardes worden gebruikt om te reageren op <code>https://{{ domain }}/.well-known/</code> URLs. Let op: de app moet bereikbaar zijn op het hoofddomein <code>{{ domain }}</code> om te kunnen werken. Lees de <a href=\"{{docsLink}}\" target=\"_blank\">documentatie</a> voor meer informatie.",
"hetznerToken": "Hetzner Token",
"cloudflareDefaultProxyStatus": "Inschakelen proxy voor nieuwe DNS regels",
"cloudflareDefaultProxyStatus": "Inschakelen Proxy voor nieuwe DNS regels",
"porkbunApikey": "API sleutel",
"porkbunSecretapikey": "Geheime API sleutel",
"bunnyAccessKey": "Bunny toegangssleutel",
@@ -626,14 +631,14 @@
"gandiTokenTypePAT": "Persoonlijke Toegang Token (PAT)",
"inwxUsername": "Gebruikersnaam",
"inwxPassword": "Wachtwoord",
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) nameservers"
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers"
},
"title": "Domeinen",
"domain": "Domein",
"provider": "Aanbieder",
"renewCerts": {
"title": "Vernieuw certificaten",
"renewAllAction": "Vernieuw alle certificaten",
"title": "Vernieuw Certificaten",
"renewAllAction": "Vernieuw alle Certificaten",
"description": "Let's Encrypt certificaten worden automatisch vernieuwd. Gebruik deze optie om nu te vernieuwen."
},
"changeDashboardDomain": {
@@ -653,7 +658,7 @@
"domainWellKnown": {
"title": "Well-Known locaties van {{ domain }}"
},
"tooltipWellKnown": "Well-Known Locaties",
"tooltipWellKnown": "Well-Known locaties",
"emptyPlaceholder": "Geen Domeinen",
"noMatchesPlaceholder": "Geen bijbehorende domein"
},
@@ -718,7 +723,7 @@
"accessControl": {
"userManagement": {
"description": "Deze app is ingesteld voor authenticatie met het Cloudron Gebruikersadresboek. Deze instelling bepaalt wie kan inloggen om de app te gebruiken.",
"dashboardVisibility": "Dashboard zichtbaarheid",
"dashboardVisibility": "Dashboardzichtbaarheid",
"visibleForSelected": "Alleen zichtbaar voor de volgende gebruikers en groepen",
"descriptionSftp": "Deze instelling regelt ook SFTP-toegang.",
"visibleForAllUsers": "Zichtbaar voor alle gebruikers op deze Cloudron"
@@ -779,8 +784,8 @@
"live": "Live",
"1h": "1 uur"
},
"diskIOTotal": "Totaal Lezen: {{ read }}. .Totaal Schrijven: {{ write }}",
"networkIOTotal": "Totaal Inkomend: {{ inbound }} Totaal Uitgaand: {{ outbound }}"
"diskIOTotal": "Totaal gelezen: {{ read }} Totaal geschreven: {{ write }}",
"networkIOTotal": "Totaal inkomend: {{ inbound }} Totaal uitgaand: {{ outbound }}"
},
"security": {
"csp": {
@@ -793,7 +798,7 @@
"disableIndexingAction": "Indexering uitschakelen",
"txtPlaceholder": "Leeg laten om toe te staan dat bots deze app indexeren"
},
"hstsPreload": "Schakel HSTS preload in voor deze site en alle subdomeinen"
"hstsPreload": "Schakel HSTS-preload in (inclusief subdomeinen)"
},
"updates": {
"info": {
@@ -809,14 +814,14 @@
"title": "Automatische Updates"
},
"updates": {
"description": "Cloudron controleert periodiek de <a href=\"https://cloudron.io\" target=\"_blank\">App Store</a> voor updates."
"description": "Cloudron controleert periodiek de App Store op updates."
}
},
"backups": {
"backups": {
"title": "Backups",
"time": "Aangemaakt op",
"downloadConfigTooltip": "Download configuratie",
"downloadConfigTooltip": "Download Configuratie",
"createBackupAction": "Maak backup",
"importAction": "Importeer backup",
"description": "Backups zijn complete momentopnamen van de app. Je kunt deze app backups gebruiken voor herstel of om de app te klonen.",
@@ -838,13 +843,13 @@
"recovery": {
"title": "Herstel Modus",
"restartAction": "Herstarten",
"description": "Om defecte plugins of onjuiste configuraties te herstellen zet je de app in <a href=\"{{ docsLink }}\" target=\"_blank\">Herstel Modus</a>.",
"description": "Om defecte plugins of onjuiste configuraties te herstellen zet je de app in Herstelmodus.",
"disableAction": "Herstel Modus uitschakelen",
"enableAction": "Herstel Modus inschakelen"
"enableAction": "Herstelmodus inschakelen"
},
"taskError": {
"title": "Taak fout",
"retryAction": "Probeer Taak {{ task }} opnieuw",
"retryAction": "Probeer taak {{ task }} opnieuw",
"description": "Indien een installatie, configuratie, update, herstel of backup resulteert in een fout, probeer de taak dan opnieuw."
},
"restart": {
@@ -1014,7 +1019,7 @@
"title": "Configureer IPv6 aanbieder"
},
"trustedIps": {
"description": "HTTP headers van bijbehorende IP adressen worden vertrouwd",
"description": "HTTP headers van bijbehorende IP adressen worden vertrouwd.",
"summary": "{{ trustCount }} IPs vertrouwd",
"title": "Configureer vertrouwde IPs"
},
@@ -1061,7 +1066,7 @@
"checkForUpdatesAction": "Controleer op updates",
"updateAvailableAction": "Update beschikbaar",
"stopUpdateAction": "Stop Update",
"description": "Platform en app updates worden toegepast met deze planning, volgens deze <a href=\"/#/system-locale\">Systeem Tijdzone</a>.",
"description": "Platform en app updates worden toegepast met deze planning en deze <a href=\"/#/system-locale\">Systeem Tijdzone</a>.",
"disabled": "Uitgeschakeld",
"schedule": "Planning",
"onLatest": "Laatste"
@@ -1073,7 +1078,7 @@
"days": "Dagen",
"hours": "Uren",
"title": "Automatische Update Planning configureren",
"description": "Stel de dagen en uren in voor automatische platform- en app-updates. Zorg ervoor dat deze planning niet overlapt met de <a href=\"/#/backups\">backup planning</a>."
"description": "Stel de dagen en uren in voor automatische updates van het platform en apps. Zorg ervoor dat dit schema niet overlapt met de back-upschema's."
},
"updateDialog": {
"title": "Update Cloudron naar",
@@ -1148,7 +1153,7 @@
"appUp": "App is weer online",
"appDown": "App werkt niet",
"rebootRequired": "Server herstart noodzakelijk",
"cloudronUpdateFailed": "Cloudron Update Mislukt",
"cloudronUpdateFailed": "Cloudron update mislukt",
"diskSpace": "Weinig diskruimte"
},
"settingsDialog": {
@@ -1176,14 +1181,18 @@
"reallyDelete": "Wil je het echt verwijderen?"
},
"newDirectoryDialog": {
"title": "Nieuwe map"
"title": "Nieuwe map",
"create": "Aanmaken"
},
"newFileDialog": {
"title": "Nieuw bestand",
"create": "Aanmaken"
},
"renameDialog": {
"reallyOverwrite": "Een bestand met die naam bestaat al. Wil je het bestaande bestand overschrijven?"
"reallyOverwrite": "Een bestand met die naam bestaat al. Wil je het bestaande bestand overschrijven?",
"title": "Hernoem {{ fileName }}",
"newName": "Nieuwe naam",
"rename": "Hernoem"
},
"toolbar": {
"new": "Nieuw",
@@ -1191,15 +1200,84 @@
"uploadFile": "Upload bestand",
"restartApp": "Herstart app",
"upload": "Upload",
"newFolder": "Nieuwe map"
"newFolder": "Nieuwe map",
"uploadFolder": "Upload map",
"openTerminal": "Open Terminal",
"openLogs": "Open logbestanden"
},
"extractionInProgress": "Bezig met uitpakken",
"pasteInProgress": "Bezig met plakken",
"deleteInProgress": "Bezig met verwijderen"
"deleteInProgress": "Bezig met verwijderen",
"chownDialog": {
"title": "Eigenaarschap veranderen",
"newOwner": "Nieuwe eigenaar",
"change": "Eigenaar aanpassen",
"recursiveCheckbox": "Eigenaar recursief aanpassen"
},
"uploadingDialog": {
"title": "Uploaden bestanden ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "Een of meerdere bestanden bestaan al.",
"errorFailed": "Uploaden van een of meerdere bestanden is mislukt. Probeer opnieuw.",
"closeWarning": "Herlaad deze pagina niet totdat het uploaden is afgerond.",
"retry": "Probeer opnieuw",
"overwrite": "Overschrijven"
},
"extractDialog": {
"title": "Uitpakken {{ fileName }}",
"closeWarning": "Herlaad deze pagina niet totdat het uitpakken is afgerond."
},
"textEditorCloseDialog": {
"title": "Bestand heeft niet-opgeslagen veranderingen",
"details": "Veranderingen gaan verloren als je ze nu niet opslaat",
"dontSave": "Niet opslaan"
},
"notFound": "Niet gevonden",
"list": {
"name": "Naam",
"size": "Grootte",
"owner": "Eigenaar",
"empty": "Geen bestanden",
"symlink": "symlink naar {{ target }}",
"menu": {
"rename": "Hernoem",
"chown": "Eigenaarschap veranderen",
"extract": "Hier uitpakken",
"download": "Download",
"delete": "Verwijderen",
"edit": "Bewerk",
"cut": "Knippen",
"copy": "Kopiëren",
"paste": "Plakken",
"selectAll": "Alles selecteren",
"open": "Open"
},
"mtime": "Bewerkt"
},
"extract": {
"error": "Fout tijdens uitpakken: {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Bestaat al"
},
"newFile": {
"errorAlreadyExists": "Bestaat al"
},
"status": {
"restartingApp": "herstarten app"
},
"uploader": {
"uploading": "Uploaden",
"exitWarning": "Uploaden nog bezig. Weet je zeker dat je deze pagina wilt sluiten?"
},
"textEditor": {
"undo": "Ongedaan maken",
"redo": "Opnieuw doen",
"save": "Opslaan"
}
},
"email": {
"config": {
"title": "E-mailconfiguratie {{ domain }}",
"title": "E-mailconfiguratie {{ domain }}",
"clientConfiguration": "Configureren E-mail programma's",
"sending": {
"title": "Versturen"
@@ -1221,7 +1299,8 @@
"usage": "Gebruik",
"title": "E-mailboxen",
"emptyPlaceholder": "Geen Mailboxen",
"noMatchesPlaceholder": "Geen bijbehorende mailboxen"
"noMatchesPlaceholder": "Geen bijbehorende mailboxen",
"stats": "Aantal: {{ mailboxCount }} / Opslaggebruik: {{ usage }}"
},
"mailinglists": {
"title": "E-maillijsten",
@@ -1229,7 +1308,7 @@
"members": "Lijst van deelnemers",
"everyoneTooltip": "Versturen naar deze lijst is toegestaan voor iedereen",
"membersOnlyTooltip": "Versturen naar deze lijst is alleen toegestaan voor deelnemers",
"emptyPlaceholder": "Geen Maillijsten",
"emptyPlaceholder": "Geen maillijsten",
"noMatchesPlaceholder": "Geen bijbehorende maillijsten"
},
"catchall": {
@@ -1251,7 +1330,7 @@
"mailRelay": {
"host": "SMTP Host",
"port": "SMTP Poort (STARTTLS)",
"selfsignedCheckbox": "Accepteer zelf-ondertekende certificaten",
"selfsignedCheckbox": "Accepteer zelf-ondertekend Certificaat",
"apiTokenOrKey": "API Token/Sleutel",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
@@ -1265,10 +1344,10 @@
"description": "Middels 'vermommen' kunnen gebruikers en apps e-mail versturen met elke willekeurige gebruikersnaam in het VAN adres."
},
"signature": {
"title": "Handtekening",
"title": "E-mailhandtekening",
"description": "Deze tekst wordt toegevoegd aan alle uitgaande e-mails van dit domein.",
"plainTextFormat": "Tekstformaat",
"htmlFormat": "HTML formaat"
"htmlFormat": "HTML-formaat"
},
"dnsStatus": {
"namecheapInfo": "Namecheap vereist handmatige handelingen voor MX records",
@@ -1313,7 +1392,7 @@
"noAliases": "Er zijn geen aliassen ingesteld.",
"addAliasAction": "Alias toevoegen",
"addAnotherAliasAction": "Een andere alias toevoegen",
"enableStorageQuota": "Inschakelen Opslag Quota"
"enableStorageQuota": "Opslagquota"
},
"deleteMailboxDialog": {
"purgeMailboxCheckbox": "Verwijder alle e-mails en filters in deze mailbox",
@@ -1328,19 +1407,19 @@
"name": "Naam"
},
"editMailinglistDialog": {
"title": "Bewerk Maillijst {{ name }}@{{ domain }}"
"title": "Bewerk Mailinglijst {{ name }}@{{ domain }}"
},
"deleteMailinglistDialog": {
"title": "Verwijder maillijst {{ name }}@{{ domain }}",
"title": "Verwijder Mailinglijst {{ name }}@{{ domain }}",
"deleteAction": "Verwijder",
"description": "Weet je zeker dat je maillijst <b>{{ name }}@{{ domain }}</b> wilt verwijderen?"
},
"updateMailboxDialog": {
"activeCheckbox": "Mailbox is actief",
"enablePop3": "POP3 toegang inschakelen"
"activeCheckbox": "Actieve Mailbox",
"enablePop3": "POP3-toegang"
},
"updateMailinglistDialog": {
"activeCheckbox": "Mailing-lijst is actief"
"activeCheckbox": "Actieve Mailinglijst"
},
"howToConnectInfoModal": "Configureren e-mail programma's"
},
@@ -1351,7 +1430,8 @@
"2faToken": "2FA Token",
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
"errorIncorrect2FAToken": "2FA token is niet geldig",
"errorInternal": "Interne fout, probeer later opnieuw"
"errorInternal": "Interne fout, probeer later opnieuw",
"loginAction": "Inloggen"
},
"passwordReset": {
"title": "Wachtwoord herstellen",
@@ -1399,7 +1479,7 @@
"editVolumeDialog": {
"title": "Bewerk volume {{ name }}"
},
"emptyPlaceholder": "Geen Volumes"
"emptyPlaceholder": "Geen volumes"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Wachtwoord herstellen",
@@ -1450,7 +1530,7 @@
},
"storage": {
"mounts": {
"description": "Gekoppelde <a href=\"/#/volumes\">volumes</a> kunnen bereikt worden via <code>/media/(volume name)</code> . Gekoppelde data is niet opgenomen in de app's backup."
"description": "Gekoppelde volumes kunnen bereikt worden via <code>/media/(volume name)</code> . Gekoppelde data is niet opgenomen in de backup van de app."
}
},
"oidc": {
@@ -1463,7 +1543,7 @@
"id": "Client ID",
"secret": "Client geheim",
"signingAlgorithm": "Ondertekeningsalgoritme",
"loginRedirectUri": "Login callback URLs (met komma gescheiden)"
"loginRedirectUri": "Login Callback URL's (met komma gescheiden)"
},
"description": "De OpenID aanbieder kan gebruikt worden door externe applicaties voor single sign-on.",
"editClientDialog": {
@@ -1478,7 +1558,7 @@
},
"clients": {
"title": "OpenID Clients",
"empty": "Geen OpenID Clients"
"empty": "Geen OpenID clients"
}
},
"userdirectory": {
@@ -1488,13 +1568,14 @@
},
"archives": {
"listing": {
"placeholder": "Geen Gearchiveerde Apps"
"placeholder": "Geen gearchiveerde apps"
}
},
"backup": {
"target": {
"label": "Backup Locatie",
"size": "Grootte"
"size": "Grootte",
"fileCount": "Bestanden"
},
"sites": {
"title": "Backup Locaties",
@@ -1513,7 +1594,7 @@
"provider": "Aanbieder",
"username": "Gebruikersnaam",
"title": "Docker Registries",
"description": "Cloudron kan <a href=\"{{ customAppsLink }}\" target=\"_blank\">custom apps</a> binnenhalen en installeren van een private docker registry.",
"description": "Cloudron kan aangepaste apps ophalen en installeren vanuit een privé Docker-registry.",
"removeDialog": {
"title": "Verwijder {{ serverAddress }}"
},
@@ -1522,7 +1603,7 @@
"dialog": {
"title": "Docker Registry"
},
"emptyPlaceholder": "Geen Docker Registries"
"emptyPlaceholder": "Geen Docker registries"
},
"dockerRegistres": {
"removeDialog": {
@@ -1537,7 +1618,7 @@
},
"externallinks": {
"label": "Externe Links",
"description": "Voeg snelkoppeling toe op het dashboard naar externe diensten"
"description": "Voeg snelkoppelingen naar externe diensten toe aan het dashboard."
},
"server": {
"title": "Server"
+28 -12
View File
@@ -15,7 +15,8 @@
"nosso": "Iniciar sessão com conta dedicada",
"email": "Iniciar sessão com endereço de correio eletrónico",
"openid": "Iniciar a sessão com Couldron OpenID"
}
},
"noMatchesPlaceholder": "Sem aplicações correspondentes"
},
"main": {
"displayName": "Nome a exibir",
@@ -39,7 +40,8 @@
"username": "Nome de Utilizador",
"actions": "Ações",
"table": {
"date": "Data"
"date": "Data",
"version": "Versão"
},
"action": {
"reboot": "Reiniciar",
@@ -84,10 +86,11 @@
"groups": "Grupos",
"configuredForCloudronEmail": "Esta aplicação está pré-configurada para ser utilizada com o <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mail do Cloudron</a>.",
"cloudflarePortWarning": "O proxy do Cloudflare deve estar desativado para o domínio da aplicação para que possa aceder a esta porta",
"portReadOnly": "apenas de leitura"
"portReadOnly": "apenas de leitura",
"ephemeralPortWarning": "Utilizar portas efémeras pode causar conflitos imprevisíveis."
},
"title": "Loja de Aplicações",
"searchPlaceholder": "Procure por alternativas, tais como Github, Dropbox, Slack, Trello, …",
"searchPlaceholder": "Procure por alternativas, tais como Github, Dropbox, Slack, Trello…",
"unstable": "Instável",
"appNotFoundDialog": {
"description": "Não existe nenhuma aplicação <b>{{ appId }}</b> com a versão <b>{{ version }}</b>.",
@@ -193,8 +196,11 @@
"url": "URL do Servidor",
"description": "Todas as consultas de LDAP tem de ser autenticadas com este segredo e o utilizador <i>{{ userDN }}</i> de DN"
},
"description": "O servidor LDAP pode ser utilizado pelas aplicações externas para autenticação.",
"cloudflarePortWarning": "O proxy de Cloudflare deve estar desativado no domínio do painel para aceder ao servidor LDAP"
"description": "O servidor LDAP permite que as aplicações externas autentiquem os utilizadores na diretoria de utilizadores do Cloudron.",
"cloudflarePortWarning": "O proxy de Cloudflare deve estar desativado no domínio do painel para aceder ao servidor LDAP",
"enable": "Ativar Servidor LDAP",
"title": "Servidor LDAP",
"enabled": "Ativar Servidor LDAP"
},
"users": {
"superadminTooltip": "Este utilizador é um super administrador",
@@ -208,13 +214,16 @@
"usermanagerTooltip": "Este utilizador pode gerir os grupos e os outros utilizadores",
"inactiveTooltip": "Utilizador está inativo",
"externalLdapTooltip": "Da diretoria LDAP externa",
"resetPasswordTooltip": "Redefinir Palavra-passe"
"resetPasswordTooltip": "Redefinir Palavra-passe",
"noMatchesPlaceholder": "Nenhum utilizador correspondente",
"emptyPlaceholder": "Sem Utilizadores"
},
"groups": {
"emptyPlaceholder": "Sem Grupos",
"name": "Nome",
"users": "Utilizadores",
"externalLdapTooltip": "Da diretoria LDAP externa"
"externalLdapTooltip": "Da diretoria LDAP externa",
"noMatchesPlaceholder": "Nenhum grupo correspondente"
},
"user": {
"fullName": "Nome Completo",
@@ -324,7 +333,7 @@
"invitationNotification": {
"body": "Mensagem enviada para {{ email }}"
},
"title": "Utilizadores e Grupos"
"title": "Utilizadores"
},
"login": {
"2faToken": "Código 2FA",
@@ -503,10 +512,10 @@
"contents": "Conteúdos",
"version": "Versão",
"noApps": "Sem Aplicações",
"appCount": "{{ appCount }} aplicações",
"appCount": "Aplicações: {{ appCount }}",
"backupNow": "Copiar Agora",
"tooltipPreservedBackup": "Esta cópia de segurança será preservada",
"title": "Listagem",
"title": "Cópias de Segurança do Sistema",
"noBackups": "Sem Cópias de Segurança",
"tooltipDownloadBackupConfig": "Transferir Configuração",
"cleanupBackups": "Limpeza das Cópias de Segurança"
@@ -516,7 +525,7 @@
"id": "Id.",
"date": "Data",
"version": "Versão",
"list": "Referencia as cópias de segurança de {{ appCount }} aplicações"
"list": "Referencia as cópias de segurança de {{ appCount }} aplicação(ões)"
}
},
"passwordReset": {
@@ -885,5 +894,12 @@
"target": {
"label": "Site da Cópia de Segurança"
}
},
"filemanager": {
"list": {
"menu": {
"download": "Transferir"
}
}
}
}
+77 -4
View File
@@ -1172,14 +1172,18 @@
"filemanager": {
"title": "Файловый менеджер",
"newDirectoryDialog": {
"title": "Новая папка"
"title": "Новая папка",
"create": "Создать"
},
"newFileDialog": {
"title": "Новый файл",
"create": "Создать"
},
"renameDialog": {
"reallyOverwrite": "Файл с таким именем уже существует. Хотите перезаписать его?"
"reallyOverwrite": "Файл с таким именем уже существует. Хотите перезаписать его?",
"title": "Переименовать {{ fileName }}",
"newName": "Новое имя",
"rename": "Переименовать"
},
"toolbar": {
"new": "Новый",
@@ -1187,14 +1191,83 @@
"newFile": "Новый файл",
"newFolder": "Новая папка",
"uploadFile": "Загрузить файл",
"restartApp": "Перезагрузить приложение"
"restartApp": "Перезагрузить приложение",
"uploadFolder": "Загрузить папку",
"openTerminal": "Открыть Терминал",
"openLogs": "Открыть логи"
},
"removeDialog": {
"reallyDelete": "Действительно удалить?"
},
"extractionInProgress": "Идёт извлечение",
"pasteInProgress": "Выполняется копирование / перемещение",
"deleteInProgress": "Выполняется удаление"
"deleteInProgress": "Выполняется удаление",
"chownDialog": {
"title": "Смена владельца",
"newOwner": "Новый владелец",
"change": "Изменить владельца",
"recursiveCheckbox": "Изменить владельца рекурсивно"
},
"uploadingDialog": {
"title": "Загрузка файлов ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "Один или несколько файлов уже существуют.",
"errorFailed": "Не удалось загрузить один или несколько файлов. Пожалуйста, попробуйте снова.",
"closeWarning": "Не обновляйте страницу, пока загрузка не будет завершена.",
"retry": "Повторить",
"overwrite": "Перезаписать"
},
"extractDialog": {
"title": "Распаковываем {{ fileName }}",
"closeWarning": "Не обновляйте страницу, пока распаковка не будет завершена."
},
"textEditorCloseDialog": {
"title": "Файл содержит несохраненные изменения",
"details": "Ваши изменения будут утеряны, если Вы не сохраните их",
"dontSave": "Не сохранять"
},
"notFound": "Не найдено",
"list": {
"name": "Имя",
"size": "Размер",
"owner": "Владелец",
"empty": "Нет файлов",
"symlink": "Символическая ссылка на {{ target }}",
"menu": {
"rename": "Переименовать",
"chown": "Изменить владельца",
"extract": "Распаковать здесь",
"download": "Скачать",
"delete": "Удалить",
"edit": "Редактировать",
"cut": "Вырезать",
"copy": "Скопировать",
"paste": "Вставить",
"selectAll": "Выбрать все",
"open": "Открыть"
},
"mtime": "Изменён"
},
"extract": {
"error": "Не удалось распаковать: {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Уже существует"
},
"newFile": {
"errorAlreadyExists": "Уже существует"
},
"status": {
"restartingApp": "перезапускаем приложение"
},
"uploader": {
"uploading": "Загружаем",
"exitWarning": "Загрузка ещё не завершена. Вы уверены, что хотите закрыть страницу?"
},
"textEditor": {
"undo": "Отменить операцию",
"redo": "Повторить операцию",
"save": "Сохранить"
}
},
"email": {
"outbound": {
+77 -4
View File
@@ -849,24 +849,97 @@
"newFolder": "Thư mục mới",
"newFile": "Tập tin mới",
"upload": "Tải lên",
"new": "Thêm mới"
"new": "Thêm mới",
"uploadFolder": "Tải thư mục lên",
"openTerminal": "Mở màn hình terminal",
"openLogs": "Mở log"
},
"renameDialog": {
"reallyOverwrite": "Trùng tên tập tin hiện có. Ghi đè lên tập tin cũ?"
"reallyOverwrite": "Trùng tên tập tin hiện có. Ghi đè lên tập tin cũ?",
"title": "Đổi tên {{ fileName }}",
"newName": "Tên mới",
"rename": "Đổi tên"
},
"newFileDialog": {
"create": "Tạo",
"title": "Tập tin mới"
},
"newDirectoryDialog": {
"title": "Thư mục mới"
"title": "Thư mục mới",
"create": "Tạo"
},
"removeDialog": {
"reallyDelete": "Chắc chắn xoá?"
},
"extractionInProgress": "Đang giải nén",
"pasteInProgress": "Đang dán",
"deleteInProgress": "Đang xoá"
"deleteInProgress": "Đang xoá",
"chownDialog": {
"title": "Đổi quyền sở hữu",
"newOwner": "Chủ sở hữu mới",
"change": "Đổi chủ sở hữu",
"recursiveCheckbox": "Đổi quyền sở hữu theo vòng lặp đệ quy"
},
"uploadingDialog": {
"title": "Đang tải lên các tập tin ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "Một hay nhiều tập tin trùng đã tồn tại.",
"errorFailed": "Không tải lên được một hay nhiều tập tin. Xin thử lại.",
"closeWarning": "Xin đừng làm mới trang đến khi việc tải lên đã hoàn thành.",
"retry": "Thử lại",
"overwrite": "Ghi đè lên"
},
"extractDialog": {
"title": "Đang giải nén {{ fileName }}",
"closeWarning": "Xin đừng làm mới trang cho đến khi việc giải nén đã xong."
},
"textEditorCloseDialog": {
"title": "Tập tin có những thay đổi chưa được lưu",
"details": "Những thay đổi của bạn sẽ bị mất nếu bạn không lưu lại",
"dontSave": "Không cần lưu"
},
"notFound": "Không tìm thấy",
"list": {
"name": "Tên",
"size": "Kích cỡ",
"owner": "Chủ sở hữu",
"empty": "Không có tập tin nào",
"symlink": "Liên kết symlink đến {{ target }}",
"menu": {
"rename": "Đổi tên",
"chown": "Đổi quyền sở hữu",
"extract": "Giải nén tại đây",
"download": "Tải xuống",
"delete": "Xoá",
"edit": "Chỉnh sửa",
"cut": "Cắt",
"copy": "Sao chép",
"paste": "Dán",
"selectAll": "Chọn tất cả",
"open": "Mở"
},
"mtime": "Đã chỉnh sửa"
},
"extract": {
"error": "Không thể giải nén: {{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "Đã tồn tại"
},
"newFile": {
"errorAlreadyExists": "Đã tồn tại"
},
"status": {
"restartingApp": "đang khởi động lại app"
},
"uploader": {
"uploading": "Đang tải lên",
"exitWarning": "Vẫn đang tải lên. Bạn có chắc muốn đóng trang này?"
},
"textEditor": {
"undo": "Hoàn tác",
"redo": "Xóa hoàn tác",
"save": "Lưu"
}
},
"terminal": {
"downloadAction": "Tải xuống",
+67 -2
View File
@@ -671,7 +671,8 @@
"filemanager": {
"title": "文件管理器",
"newDirectoryDialog": {
"title": "新文件夹"
"title": "新文件夹",
"create": "创建"
},
"newFileDialog": {
"title": "新文件",
@@ -683,10 +684,74 @@
"newFile": "新文件",
"uploadFile": "上传文件",
"restartApp": "重启应用",
"newFolder": "新文件夹"
"newFolder": "新文件夹",
"uploadFolder": "上传文件夹",
"openTerminal": "打开终端",
"openLogs": "打开日志"
},
"removeDialog": {
"reallyDelete": "确定要删除下列文件?"
},
"renameDialog": {
"title": "重命名 {{ fileName }}",
"newName": "新文件名",
"rename": "重命名"
},
"chownDialog": {
"title": "修改文件的拥有者",
"newOwner": "拥有者",
"change": "修改拥有者",
"recursiveCheckbox": "遍历文件夹修改拥有者"
},
"uploadingDialog": {
"title": "正在上传文件 ({{ countDone }}/{{ count }})",
"errorAlreadyExists": "一个或多个文件已存在。",
"errorFailed": "一个或多个文件上传失败。请重试。",
"closeWarning": "在上传完成前请不要刷新此页面。",
"retry": "重试",
"overwrite": "覆盖"
},
"extractDialog": {
"title": "正在解压 {{ fileName }}",
"closeWarning": "在解压完成前请不要刷新本页面。"
},
"textEditorCloseDialog": {
"title": "文件有未保存的修改",
"details": "如果不保存文件,您的修改将丢失",
"dontSave": "不要保存"
},
"notFound": "找不到文件",
"list": {
"name": "名称",
"size": "大小",
"owner": "拥有者",
"empty": "没有文件",
"symlink": "软链接到 {{ target }}",
"menu": {
"rename": "重命名",
"chown": "修改拥有者",
"extract": "解压到此处",
"download": "下载",
"delete": "删除",
"edit": "编辑",
"cut": "剪切",
"copy": "复制",
"paste": "粘贴",
"selectAll": "全选"
},
"mtime": "修改时间"
},
"extract": {
"error": "解压失败:{{ message }}"
},
"newDirectory": {
"errorAlreadyExists": "该目录已经存在"
},
"newFile": {
"errorAlreadyExists": "该文件已经存在"
},
"status": {
"restartingApp": "正在重启应用"
}
},
"email": {
+5 -2
View File
@@ -105,6 +105,7 @@ const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
const ready = ref(false);
const view = ref('');
const profile = ref({});
const dashboardDomain = ref('');
const subscription = ref({
plan: {},
});
@@ -227,6 +228,7 @@ async function refreshConfigAndFeatures() {
config.value = result;
features.value = result.features;
dashboardDomain.value = result.adminDomain;
}
async function onOnline() {
@@ -239,6 +241,7 @@ provide('features', features);
provide('profile', profile);
provide('refreshProfile', refreshProfile);
provide('refreshFeatures', refreshConfigAndFeatures);
provide('dashboardDomain', dashboardDomain);
onMounted(async () => {
const [error, result] = await provisionModel.status();
@@ -302,7 +305,7 @@ onMounted(async () => {
<a class="sidebar-item" :class="{ active: view === VIEWS.GROUPS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.GROUPS" @click="onSidebarClose()"><i class="fa fa-users fa-fw"></i> {{ $t('main.navbar.groups') }}</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.LDAP }" v-show="profile.isAtLeastAdmin" :href="VIEWS.LDAP" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> LDAP</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.OPENID }" v-show="profile.isAtLeastAdmin" :href="VIEWS.OPENID" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> OpenID</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.USER_DIRECTORY_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.USER_DIRECTORY_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('userdirectory.settings.title') }}</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.USER_DIRECTORY_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.USER_DIRECTORY_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('userdirectory.settings.title') }}</a>
</div>
</Transition>
@@ -313,7 +316,7 @@ onMounted(async () => {
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILBOXES }" :href="VIEWS.MAILBOXES" @click="onSidebarClose()"><i class="fa fa-fw fa-inbox"></i> {{ $t('email.incoming.mailboxes.title') }}</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILINGLISTS }" :href="VIEWS.MAILINGLISTS" @click="onSidebarClose()"><i class="fa fa-fw-solid fa-envelopes-bulk"></i> {{ $t('email.incoming.mailinglists.title') }}</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_EVENTLOG }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('emails.settings.title') }}</a>
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('emails.settings.title') }}</a>
</div>
</Transition>
+2 -17
View File
@@ -1,33 +1,18 @@
<script setup>
import { ref, onMounted } from 'vue';
import { onMounted } from 'vue';
import { FormGroup, Radiobutton, MultiSelect } from '@cloudron/pankow';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
import { ACL_OPTIONS } from '../constants.js';
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
const props = defineProps([ 'manifest', 'error', 'hideOptionalSsoOption' ]);
const props = defineProps([ 'users', 'groups', 'manifest', 'error', 'hideOptionalSsoOption' ]);
const accessRestrictionOption = defineModel('option');
const accessRestriction = defineModel('acl');
const users = ref([]);
const groups = ref([]);
const optionalSso = !!props.manifest.optionalSso;
const cloudronAuth = !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']) && !props.hideOptionalSsoOption;
onMounted(async () => {
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => u.username = u.username || u.email);
users.value = result;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
});
</script>
@@ -7,7 +7,6 @@ import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/ut
import AccessControl from './AccessControl.vue';
import PortBindings from './PortBindings.vue';
import AppsModel from '../models/AppsModel.js';
import DashboardModel from '../models/DashboardModel.js';
import DomainsModel from '../models/DomainsModel.js';
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
@@ -18,9 +17,9 @@ const STEP = Object.freeze({
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const dashboardModel = DashboardModel.create();
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
const dashboardDomain = inject('dashboardDomain');
// reactive
const busy = ref(false);
@@ -32,7 +31,6 @@ const dialog = useTemplateRef('dialogHandle');
const locationInput = useTemplateRef('locationInput');
const description = computed(() => marked.parse(manifest.value.description || ''));
const domains = ref([]);
const dashboardDomain = ref('');
const formValid = computed(() => {
if (!domain.value) return false;
@@ -167,10 +165,6 @@ function onClose() {
}
onMounted(async () => {
const [error, result] = await dashboardModel.config();
if (error) return console.error(error);
dashboardDomain.value = result.adminDomain;
});
const screenshotsContainer = useTemplateRef('screenshotsContainer');
@@ -212,7 +206,7 @@ defineExpose({
domains.value = domainList;
// preselect with dashboard domain
domain.value = (domains.value.find(d => d.domain === dashboardDomain.value) || domains.value[0]).domain;
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
tcpPorts.value = a.manifest.tcpPorts;
udpPorts.value = a.manifest.udpPorts;
@@ -231,7 +225,7 @@ defineExpose({
for (const p in secondaryDomains.value) {
const port = secondaryDomains.value[p];
port.value = port.defaultValue;
port.domain = domains.value[0].domain;
port.domain = dashboardDomain.value;
}
currentScreenshotPos = 0;
@@ -2,7 +2,7 @@
// for restore from archive or clone !
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef, computed, inject } from 'vue';
import { InputGroup, FormGroup, TextInput, SingleSelect, Dialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import PortBindings from '../components/PortBindings.vue';
@@ -14,6 +14,7 @@ const appsModel = AppsModel.create();
const archivesModel = ArchivesModel.create();
const domainsModel = DomainsModel.create();
const dashboardDomain = inject('dashboardDomain');
const appId = ref(null);
const dialog = useTemplateRef('dialog');
const restoreArchive = ref({});
@@ -119,7 +120,7 @@ defineExpose({
const app = archive.appConfig || {
subdomain: '',
domain: domains.value[0].domain,
domain: dashboardDomain.value,
secondaryDomains: [],
portBindings: {}
}; // pre-8.2 backups do not have appConfig
@@ -129,7 +130,7 @@ defineExpose({
restoreLocation.value = app.subdomain;
const d = domains.value.find(function (d) { return app.domain === d.domain; });
restoreDomain.value = d ? d.domain : domains.value[0].domain; // try to pre-select the app's domain
restoreDomain.value = d ? d.domain : dashboardDomain.value; // try to pre-select the app's domain
restoreSecondaryDomains.value = {};
needsOverwrite.value = false;
restoreArchive.value = archive;
@@ -113,7 +113,7 @@ onMounted(async () => {
<FormGroup>
<label for="providerInput">{{ $t('backups.configureBackupStorage.provider') }} <sup><a href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" />
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" required />
</FormGroup>
<!-- mountpoint -->
@@ -29,7 +29,7 @@ const formError = ref({});
const busy = ref(false);
const enableForUpdates = ref(false);
const provider = ref('');
const includeExclude = ref('everything'); // or exclude, include
const includeExclude = ref(''); // or exclude, include
const contentOptions = ref([]);
const contentInclude = ref([]);
const contentExclude = ref([]);
@@ -227,6 +227,12 @@ function onCancel() {
dialog.value.close();
}
const isValid = ref(false);
function checkValidity() {
isValid.value = form.value.checkValidity();
}
defineExpose({
async open() {
step.value = 'storage';
@@ -247,7 +253,7 @@ defineExpose({
encryptionPasswordHint.value = '';
encryptedFilenames.value = false;
limits.value = {};
includeExclude.value = 'everything';
includeExclude.value = '';
contentInclude.value = [];
contentExclude.value = [];
@@ -282,6 +288,8 @@ defineExpose({
});
dialog.value.open();
// checkValidity();
}
});
@@ -291,9 +299,9 @@ defineExpose({
<Dialog ref="dialog" :title="$t('backups.site.addDialog.title')">
<div>
<div v-if="step === 'storage'">
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @change="checkValidity()">
<fieldset :disabled="busy">
<input style="display: none;" type="submit"/>
<input style="display: none;" type="submit" :disabled="!isValid"/>
<FormGroup>
<label for="nameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
@@ -306,10 +314,10 @@ defineExpose({
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
<div description>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
<div>
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')" required/>
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')" required/>
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
<Radiobutton v-model="includeExclude" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')"/>
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')" required/>
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
</div>
</FormGroup>
@@ -370,7 +378,7 @@ defineExpose({
<div style="display: flex; gap: 6px; align-items: end;">
<Button secondary v-if="!busy" :disabled="busy" @click="onCancel()">{{ $t('main.dialog.cancel') }}</Button>
<Button primary :disabled="busy" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
<Button primary :disabled="busy || !isValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
</div>
</div>
</fieldset>
@@ -18,7 +18,6 @@ const providers = [
{ name: 'Google Cloud', value: 'google-cloud' },
{ name: 'Linode', value: 'linode' },
{ name: 'Quay', value: 'quay' },
{ name: 'Treescale', value: 'treescale' },
{ name: t('settings.registryConfig.providerOther') || 'Other', value: 'other' },
];
@@ -133,6 +133,10 @@ function onGcdnsFileInputChange(event) {
<SingleSelect v-model="provider" @select="onProviderChange" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required />
</FormGroup>
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
<!-- Route53 -->
<FormGroup v-if="provider === 'route53'">
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
@@ -259,7 +263,7 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<!-- Hetzner -->
<FormGroup v-if="provider === 'hetzner'">
<FormGroup v-if="provider === 'hetzner' || provider === 'hetznercloud'">
<label for="hetznerTokenInput">{{ $t('domains.domainDialog.hetznerToken') }}</label>
<MaskedInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
@@ -320,9 +324,5 @@ function onGcdnsFileInputChange(event) {
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name"/>
</FormGroup>
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
</div>
</template>
+1 -1
View File
@@ -60,7 +60,7 @@ function cancel() {
<Button tool plain secondary @click="cancel" :disabled="saving">{{ $t('main.dialog.cancel') }}</Button>
</div>
<div v-else>
<div v-if="markdown" v-html="marked.parse(value)"></div>
<div v-if="markdown" v-html="marked.parseInline(value)"></div>
<div v-else>{{ value }}</div>
</div>
</FormGroup>
+2 -2
View File
@@ -64,7 +64,7 @@ const uploadMenuModel = [{
action: onUploadFile,
}, {
icon: 'fa-regular fa-folder-open',
label: t('filemanager.toolbar.newFolder'),
label: t('filemanager.toolbar.uploadFolder'),
action: onUploadFolder,
}];
@@ -443,7 +443,7 @@ onMounted(async () => {
}
appLink.value = `https://${result.body.fqdn}`;
title.value = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
title.value = `${result.body.label || result.body.fqdn} ` + (result.body.manifest ? `(${result.body.manifest.title})` : '');
} else if (type === 'volume') {
let error, result;
try {
+1 -1
View File
@@ -108,7 +108,7 @@ defineExpose({
</FormGroup>
<FormGroup>
<label for="appsInput">Access to Apps</label>
<label for="appsInput">{{ $t('users.group.allowedApps') }}</label>
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
</FormGroup>
</fieldset>
+5
View File
@@ -33,6 +33,9 @@ const notificationsAllBusy = ref(false);
function onOpenNotifications(popover, event, elem) {
popover.open(event, elem);
// close after 2 seconds if there is nothing to show
if (notifications.value.length === 0) setTimeout(popover.close, 2000);
}
async function onMarkNotificationRead(notification) {
@@ -55,6 +58,8 @@ async function onMarkAllNotificationRead() {
await refresh();
notificationsAllBusy.value = false;
if (notifications.value.length === 0) setTimeout(helpPopover.value.close, 2000);
}
async function refresh() {
+1 -1
View File
@@ -57,7 +57,7 @@ onMounted(async () => {
if (error) return console.error(error);
ldapUrl.value = `ldaps://${result.adminFqdn}:636`;
adminDomain.value = domains.find(d => d.domain === result.adminDomain) || domains[0];
adminDomain.value = domains.find(d => d.domain === result.adminDomain);
[error, result] = await userDirectoryModel.getExposedLdapConfig();
if (error) return console.error(error);
+5 -3
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef, computed, inject } from 'vue';
import { Dialog, Button, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
import { prettyDecimalSize } from '@cloudron/pankow/utils';
import MailboxesModel from '../models/MailboxesModel.js';
@@ -10,6 +10,7 @@ const props = defineProps([ 'apps', 'users', 'groups', 'domains' ]);
const mailboxesModel = MailboxesModel.create();
const dashboardDomain = inject('dashboardDomain');
const dialog = useTemplateRef('dialog');
const busy = ref(false);
const formError = ref('');
@@ -34,7 +35,8 @@ const domainHasIncomingEnabled = computed(() => {
function onAddAlias() {
aliases.value.push({
name: '',
domain: '@' + props.domains[0].domain,
domain: dashboardDomain.value,
label: '@' + dashboardDomain.value,
});
}
@@ -91,7 +93,7 @@ defineExpose({
mailbox.value = m;
name.value = m ? m.name : '';
domain.value = m ? m.domain : props.domains[0].domain;
domain.value = m ? m.domain : dashboardDomain.value;
ownerId.value = m ? m.ownerId : '';
aliases.value = m ? m.aliases : [];
active.value = m ? m.active : true;
@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { ref, useTemplateRef, inject } from 'vue';
import { Dialog, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
import MailinglistsModel from '../models/MailinglistsModel.js';
@@ -19,6 +19,7 @@ const membersText = ref('');
const membersOnly = ref(false);
const active = ref(true);
const domainList = ref([]);
const dashboardDomain = inject('dashboardDomain');
async function onSubmit() {
busy.value = true;
@@ -63,7 +64,7 @@ defineExpose({
mailinglist.value = m;
name.value = m ? m.name : '';
domain.value = m ? m.domain : props.domains[0].domain;
domain.value = m ? m.domain : dashboardDomain.value;
membersText.value = m ? m.members.join('\n') : '';
membersOnly.value = m ? m.membersOnly : false;
active.value = m ? m.active : true;
@@ -1,29 +1,13 @@
<script setup>
import { ref, onMounted } from 'vue';
import { onMounted } from 'vue';
import { FormGroup, MultiSelect } from '@cloudron/pankow';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
defineProps(['hasFtp']);
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
defineProps(['hasFtp', 'users', 'groups']);
const accessRestriction = defineModel('acl');
const users = ref([]);
const groups = ref([]);
onMounted(async () => {
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => u.username = u.username || u.email);
users.value = result;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
});
</script>
+52 -9
View File
@@ -6,7 +6,7 @@ const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, ClipboardAction, Menu, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
import { TASK_TYPES } from '../constants.js';
import Section from '../components/Section.vue';
import BackupsModel from '../models/BackupsModel.js';
@@ -56,6 +56,25 @@ const columns = {
actions: {}
};
const backupContentTableColumns = {
label: {
label: t('backups.listing.contents'),
sort: true,
},
fileCount: {
label: t('backup.target.fileCount'),
sort(a, b, A, B) {
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
},
},
size: {
label: t('backup.target.size'),
sort(a, b, A , B) {
return A.stats?.upload?.size - B.stats?.upload?.size;
},
}
};
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(backup, event) {
@@ -183,10 +202,13 @@ async function onDownloadConfig(backup) {
// backups info dialog
const infoDialog = useTemplateRef('infoDialog');
const infoDialogBusy = ref(true);
const infoBackup = ref({ contents: [] });
async function onInfo(backup) {
infoBackup.value = backup;
infoBackup.value.contents = [];
infoDialogBusy.value = true;
infoDialog.value.open();
// amend detailed app info
@@ -221,6 +243,8 @@ async function onInfo(backup) {
}
infoBackup.value.contents.push(content);
}
infoDialogBusy.value = false;
}
// edit backups dialog
@@ -307,16 +331,35 @@ defineExpose({ refresh });
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
<div class="info-value">{{ infoBackup.packageVersion }}</div>
</div>
<div class="info-row" v-if="infoBackup.stats?.aggregatedUpload">
<div class="info-label">{{ $t('backups.backupDetails.size') }}</div>
<div class="info-value">{{ prettyFileSize(infoBackup.stats.aggregatedUpload.size) }} | {{ infoBackup.stats.aggregatedUpload.fileCount }} file(s)</div>
</div>
<div class="info-row" v-if="infoBackup.stats?.aggregatedCopy">
<div class="info-label">{{ $t('backups.backupDetails.duration') }}</div>
<div class="info-value">{{ prettyDuration(infoBackup.stats.aggregatedUpload.duration + infoBackup.stats.aggregatedCopy.duration) }}</div>
</div>
<br/>
<div>{{ $t('backups.backupDetails.list', { appCount: infoBackup.appCount }) }}:</div>
<br/>
<p class="text-muted">{{ $t('backups.backupDetails.list', { appCount: infoBackup.contents.length }) }}:</p>
<div v-for="content in infoBackup.contents" :key="content.id">
<a v-if="content.id === 'mail'" href="/#/mailboxes">{{ content.label }}</a>
<a v-else-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
<span v-if="content.stats">&nbsp;{{ prettyFileSize(content.stats.size) }} - {{ content.stats.fileCount }} file(s)</span>
</div>
<TableView :columns="backupContentTableColumns" :model="infoBackup.contents" :busy="infoDialogBusy">
<template #label="content">
<a v-if="content.id === 'mail'" href="/#/mailboxes">{{ content.label }}</a>
<a v-else-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
</template>
<template #fileCount="content">
<div v-if="content.stats?.upload" style="text-align: right">{{ content.stats.upload.fileCount }}</div>
<div v-else style="text-align: right">-</div>
</template>
<!-- <td>{{ prettyDuration(content.stats.upload.duration | content.stats.copy.duration) }}</td> -->
<template #size="content">
<div v-if="content.stats?.upload" style="text-align: right">{{ prettyFileSize(content.stats.upload.size) }}</div>
<div v-else style="text-align: right">-</div>
</template>
</TableView>
</Dialog>
<Dialog ref="editDialog"
@@ -360,7 +403,7 @@ defineExpose({ refresh });
</template>
<template #size="backup">
<span v-if="backup.stats?.aggregated">{{ prettyFileSize(backup.stats.aggregated.size) }} - {{ backup.stats.aggregated.fileCount }} file(s)</span>
<span v-if="backup.stats?.aggregatedUpload">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s)</span>
</template>
<template #site="backup">{{ backup.site.name }}</template>
+43 -13
View File
@@ -6,39 +6,64 @@ import AccessControl from '../AccessControl.vue';
import OperatorAccessControl from '../OperatorAccessControl.vue';
import AppsModel from '../../models/AppsModel.js';
import { ACL_OPTIONS } from '../../constants.js';
import UsersModel from '../../models/UsersModel.js';
import GroupsModel from '../../models/GroupsModel.js';
const props = defineProps([ 'app' ]);
const appsModel = AppsModel.create();
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
const busy = ref(false);
const users = ref([]);
const groups = ref([]);
const loading = ref(false);
const submitBusy = ref(false);
const errorMessage = ref('');
const accessRestrictionOption = ref(ACL_OPTIONS.ANY);
const accessRestrictionAcl = ref({ users: [], groups: [] });
const operatorAcl = ref({ users: [], groups: [] });
async function onSubmit() {
busy.value = true;
submitBusy.value = true;
errorMessage.value = '';
let [error] = await appsModel.configure(props.app.id, 'access_restriction', { accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? false : accessRestrictionAcl.value) });
if (error) {
errorMessage.value = error.body ? error.body.message : 'Internal error';
busy.value = false;
submitBusy.value = false;
return console.error(error);
}
[error] = await appsModel.configure(props.app.id, 'operators', { operators: (operatorAcl.value.users.length || operatorAcl.value.groups.length) ? operatorAcl.value : null});
if (error) {
errorMessage.value = error.body ? error.body.message : 'Internal error';
busy.value = false;
submitBusy.value = false;
return console.error(error);
}
busy.value = false;
submitBusy.value = false;
}
onMounted(() => {
onMounted(async () => {
loading.value = true;
let [error, result] = await usersModel.list();
if (error) return console.error(error);
const userIds = new Set();
for (const u of result) {
u.username = u.username || u.email; // ensure username
userIds.add(u.id);
}
users.value = result;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
const groupIds = new Set();
for (const g of result) groupIds.add(g.id);
if (props.app.accessRestriction === null) {
accessRestrictionOption.value = ACL_OPTIONS.ANY;
accessRestrictionAcl.value = { users: [], groups: [] };
@@ -47,26 +72,31 @@ onMounted(() => {
accessRestrictionAcl.value = { users: [], groups: [] };
} else {
accessRestrictionOption.value = ACL_OPTIONS.RESTRICTED;
accessRestrictionAcl.value = props.app.accessRestriction;
accessRestrictionAcl.value = JSON.parse(JSON.stringify(props.app.accessRestriction)); // make a copy
accessRestrictionAcl.value.users = accessRestrictionAcl.value.users.filter(uid => userIds.has(uid)); // remove deleted users
accessRestrictionAcl.value.groups = accessRestrictionAcl.value.groups.filter(gid => groupIds.has(gid)); // remove deleted groups
}
operatorAcl.value = { users: [], groups: [] };
if (props.app.operators) {
operatorAcl.value.users = props.app.operators.users;
operatorAcl.value.groups = props.app.operators.groups;
operatorAcl.value = JSON.parse(JSON.stringify(props.app.operators)); // make a copy
operatorAcl.value.users = operatorAcl.value.users.filter(uid => userIds.has(uid)); // remove deleted users
operatorAcl.value.groups = operatorAcl.value.groups.filter(gid => groupIds.has(gid)); // remove deleted groups
}
loading.value = false;
});
</script>
<template>
<div>
<div v-if="!loading">
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="app.manifest" :hide-optional-sso-option="!app.sso"/>
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :users="users" :groups="groups" :manifest="app.manifest" :hide-optional-sso-option="!app.sso"/>
<br/>
<OperatorAccessControl v-model:acl="operatorAcl" :has-ftp="app.manifest.addons?.localstorage?.ftp"/>
<OperatorAccessControl v-model:acl="operatorAcl" :users="users" :groups="groups" :has-ftp="app.manifest.addons?.localstorage?.ftp"/>
<br/>
<br/>
<Button @click="onSubmit()" :loading="busy" :disabled="busy">{{ $t('main.dialog.save') }}</Button>
<Button @click="onSubmit()" :loading="submitBusy" :disabled="submitBusy">{{ $t('main.dialog.save') }}</Button>
</div>
</template>
+1 -1
View File
@@ -425,7 +425,7 @@ onMounted(async () => {
{{ backup.site.name }}
</template>
<template #size="backup">
<span v-if="backup.stats">{{ prettyFileSize(backup.stats.size) }} - {{ backup.stats.fileCount }} file(s)</span>
<span v-if="backup.stats?.upload">{{ prettyFileSize(backup.stats.upload.size) }} - {{ backup.stats.upload.fileCount }} file(s)</span>
</template>
<template #actions="backup">
<div style="text-align: right;">
+1 -1
View File
@@ -81,7 +81,7 @@ onMounted(() => {
<div class="info-row">
<div class="info-label">{{ $t('app.updates.info.description') }}</div>
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.version }}</div>
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
<div class="info-value" v-else>{{ app.manifest.dockerImage }}</div>
</div>
+13 -4
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, inject } from 'vue';
import { Button, SingleSelect, InputGroup, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
import { isValidDomain } from '@cloudron/pankow/utils';
import { ISTATES } from '../../constants.js';
@@ -13,6 +13,7 @@ const props = defineProps([ 'app' ]);
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const dashboardDomain = inject('dashboardDomain');
const domains = ref([]);
const busy = ref(false);
const errorMessage = ref('');
@@ -38,7 +39,7 @@ function isNoopOrManual(domain) {
function onAddAlias() {
aliases.value.push({
domain: domains.value[0].domain,
domain: dashboardDomain.value,
subdomain: ''
});
}
@@ -49,7 +50,7 @@ function onRemoveAlias(index) {
function onAddRedirect() {
redirects.value.push({
domain: domains.value[0].domain,
domain: dashboardDomain.value,
subdomain: ''
});
}
@@ -63,8 +64,16 @@ const formValid = computed(() => {
}];
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
for (const d of aliases.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
for (const d of aliases.value) {
let subdomain = d.subdomain;
// see apps.js:validateLocations()
if (d.subdomain.startsWith('*')) {
if (subdomain === '*') continue;
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
}
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
}
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) return false;
+1 -1
View File
@@ -41,7 +41,7 @@ export function createDirectoryModel(origin, accessToken, api) {
}
if (error || result.status !== 200) {
if (error.status === 404) return [];
if (result.status === 404) return [];
console.error('Failed to list files', error || result.status);
return [];
+2
View File
@@ -13,6 +13,7 @@ const providers = [
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Hetzner Cloud', value: 'hetznercloud' },
{ name: 'INWX', value: 'inwx' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
@@ -53,6 +54,7 @@ function filterConfigForProvider(provider, config) {
props = ['accessToken'];
break;
case 'hetzner':
case 'hetznercloud':
props = ['token'];
break;
case 'vultr':
+1 -1
View File
@@ -9,7 +9,7 @@ function create() {
async list(acknowledged = false) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 100 });
} catch (e) {
return [e];
}
+1 -1
View File
@@ -81,7 +81,7 @@ onMounted(async () => {
<div class="container">
<div class="view">
<h1 style="text-align: center;">Welcome to Cloudron</h1>
<h3 style="text-align: center;">Set up Admin Account</h3>
<h3 style="text-align: center;">Create Admin Account</h3>
<div class="has-error" v-if="formError.generic">{{ formError.generic }}</div>
+2 -2
View File
@@ -307,8 +307,8 @@ onBeforeUnmount(() => {
TODO check if this should be shown on stop confirmation
<div>{{ $t('app.uninstall.startStop.description') }}</div>
-->
<Button v-if="!app.progress && targetRunState() === TARGET_RUN_STATE.START" secondary plain tool icon="fa-solid fa-circle-play" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.startAction')" @click="onStartApp()"/>
<Button v-else-if="!app.progress" secondary plain tool icon="fa-solid fa-circle-stop" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.stopAction')" @click="onStopApp()"/>
<Button v-if="!app.progress && targetRunState() === TARGET_RUN_STATE.START" secondary tool icon="fa-solid fa-circle-play" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.startAction')" @click="onStartApp()"/>
<Button v-else-if="!app.progress" secondary tool icon="fa-solid fa-circle-stop" :loading="toggleRunStateBusy" :disabled="toggleRunStateBusy" v-tooltip="$t('app.uninstall.startStop.stopAction')" @click="onStopApp()"/>
<ButtonGroup>
<Button secondary tool :href="`/logs.html?appId=${app.id}`" target="_blank" v-tooltip="$t('app.logsActionTooltip')" icon="fa-solid fa-align-left" />
+14 -5
View File
@@ -149,6 +149,11 @@ const filteredApps = computed(() => {
if (stateFilter.value === 'update_available') return a.updateInfo;
return a.runState === RSTATES.RUNNING && (a.health !== HSTATES.HEALTHY || a.installationState !== ISTATES.INSTALLED); // not responding
}).sort((a, b) => {
const labelA = a.label || a.subdomain || a.fqdn;
const labelB = b.label || b.subdomain || b.fqdn;
return labelA.localeCompare(labelB);
});
});
@@ -280,9 +285,9 @@ onDeactivated(() => {
{{ $t('apps.title') }}
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 10px;">
<TextInput v-model="filter" :placeholder="$t('apps.searchPlaceholder')" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="stateFilterOptions" option-key="id" v-model="stateFilter" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="stateFilterOptions" option-key="id" v-model="stateFilter" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter" />
<Button tool outline secondary @click="toggleView()" :icon="viewType === VIEW_TYPE.GRID ? 'fas fa-list' : 'fas fa-grip'"></Button>
</div>
</h1>
@@ -296,7 +301,7 @@ onDeactivated(() => {
</div>
<TransitionGroup name="grid-animation" tag="div" class="grid" v-if="viewType === VIEW_TYPE.GRID">
<a v-for="app in filteredApps" :key="app.id" class="grid-item" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank" :style="{ width: itemWidth }">
<a v-for="app in filteredApps" :key="app.id" class="grid-item" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank" :style="{ width: itemWidth }">
<img :alt="app.label || app.subdomain || app.fqdn" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
<div class="grid-item-label" v-fit-text>{{ app.label || app.subdomain || app.fqdn }}</div>
<div class="grid-item-task-label" v-if="app.type === APP_TYPES.LINK">{{ $t('app.appLink.title') }}</div>
@@ -317,7 +322,7 @@ onDeactivated(() => {
<TableView :columns="listColumns" :model="filteredApps">
<template #icon="app">
<a :href="'https://' + app.fqdn" target="_blank">
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
</a>
</template>
<template #label="app">
@@ -402,6 +407,10 @@ tr:hover .action-button {
transform: translateY(-30px);
}
.item-inactive {
filter: grayscale(1);
}
.list-icon {
width: 32px;
height: 32px;
+3 -1
View File
@@ -204,6 +204,8 @@ onActivated(async () => {
onHashChange();
window.addEventListener('resize', setItemWidth);
setTimeout(() => searchInput.value.focus(), 0);
});
onDeactivated(() => {
@@ -223,7 +225,7 @@ onDeactivated(() => {
<div class="filter-bar">
<SingleSelect v-model="category" :options="categories" option-key="id" option-label="label" :disabled="!ready"/>
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;"/>
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;" autocomplete="off"/>
</div>
<div v-if="!ready" style="margin-top: 15px">
+8 -3
View File
@@ -5,7 +5,7 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, reactive, inject } from 'vue';
import { Button, Menu, ProgressBar, InputDialog, Spinner } from '@cloudron/pankow';
import { Button, Menu, ProgressBar, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import Section from '../components/Section.vue';
import StateLED from '../components/StateLED.vue';
@@ -65,8 +65,13 @@ function prettyBackupSchedule(pattern) {
prettyDay = days.map(function (day) { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(',');
}
const prettyHour = hours.map(function (hour) { return cronHours[parseInt(hour, 10)].name; }).join(',');
return prettyDay + ' at ' + prettyHour;
let prettyHour;
if (hours.length === 24 || hours[0] === '*') {
prettyHour = 'hourly';
} else {
prettyHour = hours.map(function (hour) { return cronHours[parseInt(hour, 10)].name; }).join(',');
}
return prettyDay + ' @ ' + prettyHour;
};
function prettyBackupRetention(retention) {
+2 -2
View File
@@ -131,10 +131,10 @@ onMounted(async () => {
</div>
<div v-else>
<div v-if="domain.inboundEnabled">
Outbound (via {{ prettyRelayProviderName(domain.relayProvider) }}) - {{ $t('emails.domains.stats', { mailboxCount: domain.mailboxCount, usage: prettyDecimalSize(domain.usage) }) }}
{{ $t('emails.domains.inbound') }}. Relayed via {{ prettyRelayProviderName(domain.relayProvider) }}. {{ $t('emails.domains.stats', { mailboxCount: domain.mailboxCount, usage: prettyDecimalSize(domain.usage) }) }}
</div>
<div v-else>
<span v-if="domain.outboundEnabled">{{ $t('emails.domains.outbound') }} (via {{ prettyRelayProviderName(domain.relayProvider) }})</span>
<span v-if="domain.outboundEnabled">{{ $t('emails.domains.outbound') }}. Relayed via {{ prettyRelayProviderName(domain.relayProvider) }}</span>
<span v-else>{{ $t('emails.domains.disabled') }}</span>
</div>
</div>
+1 -1
View File
@@ -230,7 +230,7 @@ onMounted(async () => {
<Section :title="$t('email.incoming.mailboxes.title')">
<template #header-title-extra>
<span style="font-weight: normal; font-size: 14px">({{ $t('emails.domains.stats', { mailboxCount: filteredMailboxes.length, usage: prettyDecimalSize(filteredMailboxesUsage) }) }})</span>
<span style="font-weight: normal; font-size: 14px">({{ $t('email.incoming.mailboxes.stats', { mailboxCount: filteredMailboxes.length, usage: prettyDecimalSize(filteredMailboxesUsage) }) }})</span>
</template>
<template #header-buttons>
<TextInput :placeholder="$t('main.searchPlaceholder')" style="flex-grow: 1;" v-model="searchFilter"/>
+1 -1
View File
@@ -132,7 +132,7 @@ onMounted(async () => {
</fieldset>
<div class="actions">
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken" :loading="busy">{{ $t('login.signInAction') }}</Button>
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken" :loading="busy">{{ $t('login.loginAction') }}</Button>
</div>
</form>
</div>
+6 -1
View File
@@ -151,9 +151,14 @@ onMounted(async () => {
[error, result] = await cloudronModel.languages();
languages.value = result.map(l => {
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
// hack for chinese simplified
l = l === 'zh_Hans' ? 'zh' : l;
return {
id: l,
display: t(`lang.${l}`, l /* default fallback */, { locale: 'en' })
display: displayNames.of(l) || l,
};
}).sort((a, b) => {
return a.display.localeCompare(b.display);
+1 -1
View File
@@ -16,7 +16,7 @@ const loginUrl = window.cloudron.loginUrl;
<small>{{ $t('login.loginTo') }}</small>
<h1>{{ name }}</h1>
<br/>
<Button id="loginProceedButton" :href="loginUrl">{{ $t('login.signInAction') }}</Button>
<Button id="loginProceedButton" :href="loginUrl">{{ $t('login.loginAction') }}</Button>
</div>
</div>
</template>
+1 -1
View File
@@ -149,7 +149,7 @@ onMounted(async () => {
// aws marketplace made a policy change that they one cannot provide route53 IAM credentials
provider.value = 'wildcard';
} else { // some default to make the form not feel "empty"
provider.value = 'hetzner';
provider.value = 'digitalocean';
}
const [error2, result] = await provisionModel.detectIp();
+6 -1
View File
@@ -58,9 +58,14 @@ onMounted(async () => {
if (error) return console.error(error);
allLanguages.value = result.map(l => {
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
// hack for chinese simplified
l = l === 'zh_Hans' ? 'zh' : l;
return {
id: l,
display: t(`lang.${l}`, l /* default fallback */, { locale: 'en' })
display: displayNames.of(l) || l,
};
}).sort((a, b) => {
return a.display.localeCompare(b.display);
@@ -50,7 +50,7 @@ onMounted(async () => {
<template>
<div class="content">
<Section :title="$t('users.title')" :title-badge="!features.profileConfig ? 'Upgrade' : ''">
<Section :title="$t('users.settings.title')" :title-badge="!features.profileConfig ? 'Upgrade' : ''">
<SettingsItem>
<div>{{ $t('users.settings.allowProfileEditCheckbox') }} <sup><a href="https://docs.cloudron.io/user-directory/#lock-profile" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
<Switch v-model="editableUserProfiles" @change="onToggleEditableUserProfiles" :disabled="busy || !features.profileConfig"/>
@@ -101,7 +101,7 @@ exports.up = async function (db) {
await db.runSql('START TRANSACTION');
await db.runSql('INSERT INTO backupSites (id, name, provider, configJson, limitsJson, integrityKeyPairJson, retentionJson, schedule, encryptionJson, format, enableForUpdates) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ id, name, provider, JSON.stringify(config), JSON.stringify(limits), JSON.stringify(integrityKeyPair), JSON.stringify(retention), schedule, JSON.stringify(encryption), format, enableForUpdates ]);
[ id, name, provider, JSON.stringify(config), JSON.stringify(limits), JSON.stringify(integrityKeyPair), JSON.stringify(retention), schedule, encryption ? JSON.stringify(encryption) : null, format, enableForUpdates ]);
await deleteOldSettings(db);
await db.runSql('UPDATE tasks SET type=? WHERE type=?', [ `backup_${id}`, 'backup' ]); // migrate the tasks
await db.runSql('COMMIT');
@@ -2,7 +2,8 @@
const crypto = require('node:crypto'),
path = require('node:path'),
paths = require('../src/paths.js');
paths = require('../src/paths.js'),
safe = require('safetydance');
exports.up = async function(db) {
const backups = await db.runSql('SELECT format, COUNT(*) AS count FROM backups GROUP BY format WITH ROLLUP', []); // https://dev.mysql.com/doc/refman/8.4/en/group-by-modifiers.html
@@ -39,7 +40,7 @@ exports.up = async function(db) {
await db.runSql('ALTER TABLE backups ADD siteId VARCHAR(128)');
if (totalCount) {
if (currentBackupSite.format === 'tgz') {
const ext = currentBackupSite.encryptionJson ? '.tar.gz.enc' : '.tar.gz';
const ext = safe.JSON.parse(currentBackupSite.encryptionJson) ? '.tar.gz.enc' : '.tar.gz';
console.log(`Adjusting remotePath of existing tgz backups with ${ext}`);
await db.runSql('UPDATE backups SET remotePath=CONCAT(remotePath, ?) WHERE format=?', [ ext, 'tgz' ]);
}
+4 -4
View File
@@ -12,7 +12,7 @@
"@aws-sdk/client-s3": "^3.901.0",
"@aws-sdk/lib-storage": "^3.901.0",
"@cloudron/connect-lastmile": "^2.3.0",
"@cloudron/manifest-format": "^5.28.0",
"@cloudron/manifest-format": "^5.29.0",
"@cloudron/superagent": "^1.0.0",
"@google-cloud/dns": "^5.3.0",
"@google-cloud/storage": "^7.17.1",
@@ -1141,9 +1141,9 @@
"license": "MIT"
},
"node_modules/@cloudron/manifest-format": {
"version": "5.28.0",
"resolved": "https://registry.npmjs.org/@cloudron/manifest-format/-/manifest-format-5.28.0.tgz",
"integrity": "sha512-XunisSYvpZgdI2i489nRXr6pLa5JYJj95xIoULOSqt0rf2LSbo70x1hGEujuTFS8LXnv0UMu3UFhEXf5exPdzg==",
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/@cloudron/manifest-format/-/manifest-format-5.29.0.tgz",
"integrity": "sha512-F0+pZ/ibs6jZAEXa0mKQBcFMLG4zmz4Qjkdx8irM4/1kbkIcvKTBaU1oRt6Uz8F7LSvlc1/D5sK45JKEkrNlCQ==",
"license": "MIT",
"dependencies": {
"cron": "^4.3.1",
+1 -1
View File
@@ -16,7 +16,7 @@
"@aws-sdk/client-s3": "^3.901.0",
"@aws-sdk/lib-storage": "^3.901.0",
"@cloudron/connect-lastmile": "^2.3.0",
"@cloudron/manifest-format": "^5.28.0",
"@cloudron/manifest-format": "^5.29.0",
"@cloudron/superagent": "^1.0.0",
"@google-cloud/dns": "^5.3.0",
"@google-cloud/storage": "^7.17.1",
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const superagent = require('@cloudron/superagent');
const translations = require('../dashboard/public/translation/en.json');
// get token from https://translate.cloudron.io/accounts/profile/#api
const token = '';
const flat = flattenObject(translations);
const keys = Object.keys(flat);
function flattenObject(obj, prefix = '', result = {}) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
// Rekursiver Aufruf für verschachtelte Objekte
flattenObject(obj[key], newKey, result);
} else {
// Wert direkt zuweisen
result[newKey] = obj[key];
}
}
}
return result;
}
async function listFiles(dir) {
const files = await fs.promises.readdir(dir);
let result = [];
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = await fs.promises.stat(fullPath);
if (stat.isDirectory()) {
result = result.concat(await listFiles(fullPath));
} else if (stat.isFile()) {
result.push(fullPath);
}
}
return result;
}
async function grepFile(fullPath, searchString) {
const content = await fs.promises.readFile(fullPath, 'utf-8');
return content.includes(searchString);
}
async function findDuplicates() {
const duplicates = [];
for (const key of keys) {
const value = flat[key];
const dups = keys.filter(k => flat[k] === value && k !== key);
if (dups.length === 0) continue;
// skip double entries
if (duplicates.find(d => d.value === value)) continue;
duplicates.push({ key, value, dups });
}
console.log('Keys with the same values multiple times:');
console.log('----------------------------------------------------------');
duplicates.forEach(d => console.log(`${d.key}\n\t${d.dups.join(' \n\t')} \n => "${d.value}"\n `));
console.log('----------------------------------------------------------');
console.log(' Count: ' + duplicates.length);
console.log('----------------------------------------------------------\n\n');
}
async function findUnused() {
const keyUsage = {};
let files = await listFiles(path.resolve('./dashboard/src/'));
files = files.concat(await listFiles(path.resolve('./src/')));
for (const file of files) {
const content = await fs.promises.readFile(file, 'utf-8');
for (const key of keys) {
if (!keyUsage[key]) keyUsage[key] = 0;
if (content.includes(key)) keyUsage[key]++;
}
}
const unusedKeys = Object.keys(keyUsage).filter(k => keyUsage[k] === 0)
console.log('Unused keys:');
console.log('----------------------------------------------------------');
console.log(unusedKeys.join('\n'));
console.log('----------------------------------------------------------');
console.log(' Count: ' + unusedKeys.length);
console.log('----------------------------------------------------------\n\n');
return unusedKeys;
}
async function purge(key) {
let result = await superagent.get('https://translate.cloudron.io/api/units/')
.set('Authorization', `Token ${token}`)
.query({q: `language:en AND component:dashboard AND key:${key}`});
const id = result.body.results[0].id;
console.log(` => deleting ${key} with id ${id}`);
await superagent.del(`https://translate.cloudron.io/api/units/${id}/`)
.set('Authorization', `Token ${token}`);
console.log(' done');
}
(async () => {
console.log(`Found ${keys.length} strings`);
console.log('----------------------------------------------------------\n\n');
console.log('Toplevel keys to be moved:');
console.log('----------------------------------------------------------');
console.log(Object.keys(flat).find(k => k.indexOf('.') === -1));
console.log('----------------------------------------------------------\n\n');
await findDuplicates();
// const unused = await findUnused();
// for (const key of unused) {
// await purge(key);
// }
})();
+2
View File
@@ -346,6 +346,8 @@ async function registerCloudron3() {
async function unlinkAccount() {
debug('unlinkAccount: Unlinking existing account.');
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
await unregister();
return await registerCloudron3();
}
+4 -4
View File
@@ -119,7 +119,7 @@ async function cleanupAppBackups(site, referencedBackupIds, progressCallback) {
const allAppIds = allApps.map(a => a.id);
// high number, try to get all app backups as we had a cloudron with over 100 apps with 4 daily backups for one month!
const appBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_APP, 1, 100000);
const appBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_APP, site.id, 1, 100000);
// collate the backups by app id. note that the app could already have been uninstalled
const appBackupsById = {};
@@ -155,7 +155,7 @@ async function cleanupMailBackups(site, referencedBackupIds, progressCallback) {
const removedMailBackupPaths = [];
const mailBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_MAIL, 1, 100000);
const mailBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_MAIL, site.id, 1, 100000);
applyBackupRetention(mailBackups, Object.assign({ keepLatest: true }, site.retention), referencedBackupIds);
@@ -180,7 +180,7 @@ async function cleanupBoxBackups(site, progressCallback) {
// We need to fetch all box backups to be able to compile a list of all referenced app backupSites.
// Otherwise if we miss some app backups, they will get purged!
// 100000 here should be seen as infinity
const boxBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, 1, 100000);
const boxBackups = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, site.id, 1, 100000);
applyBackupRetention(boxBackups, Object.assign({ keepLatest: true }, site.retention), [] /* references */);
@@ -213,7 +213,7 @@ async function cleanupMissingBackups(backupSite, progressCallback) {
let page = 1, result = [];
do {
result = await backups.list(page, perPage);
result = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, backupSite.id, page, perPage);
for (const backup of result) {
if (backup.state !== backups.BACKUP_STATE_NORMAL) continue; // note: errored and incomplete backups are cleaned up by the backup retention logic
+2 -4
View File
@@ -71,7 +71,7 @@ async function addFile(sourceFile, encryption, uploader, progressCallback) {
await uploader.finish();
return {
stats: ps.stats(), // { startTime, totalMsecs, transferred }
stats: { transferred: ps.stats().transferred },
integrity: { size: ps.stats().transferred, sha256: hash.digest('hex') }
};
}
@@ -91,8 +91,6 @@ async function sync(backupSite, remotePath, dataLayout, progressCallback) {
transferred: 0,
size: [...integrityMap.values()].reduce((sum, integrity) => sum + (integrity?.size || 0), 0), // integrity can be null if file had disappeared during upload
fileCount: addQueue.length + integrityMap.size, // final file count, not the transferred file count
startTime: Date.now(),
totalMsecs: 0
};
const destPathIntegrityMap = new Map(); // unlike integrityMap which contains local filenames, this contains destination filenames (maybe encrypted)
@@ -138,7 +136,7 @@ async function sync(backupSite, remotePath, dataLayout, progressCallback) {
await syncer.finalize(integrityMap, cacheFile);
return {
stats: { ...aggregatedStats, totalMsecs: Date.now()-aggregatedStats.startTime },
stats: aggregatedStats,
integrityMap: destPathIntegrityMap
};
}
+2 -2
View File
@@ -170,12 +170,12 @@ async function tarPack(dataLayout, encryption, uploader, progressCallback) {
const [error] = await pipeline; // already wrapped in safe()
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, `tarPack pipeline error: ${error.message}`);
const stats = ps.stats();
const stats = ps.stats(); // { startTime, totalMsecs, transferred }
debug(`tarPack: pipeline finished: ${JSON.stringify(stats)}`);
await uploader.finish();
return {
stats: { fileCount, size: stats.transferred, ...stats },
stats: { fileCount, size: stats.transferred, transferred: stats.transferred },
integrity: { size: stats.transferred, fileCount, sha256: hash.digest('hex') }
};
}
+6 -36
View File
@@ -6,9 +6,6 @@ exports = module.exports = {
getLatestInTargetByIdentifier, // brutal function name
add,
update,
setState,
list,
listBySiteId,
listByTypePaged,
del,
@@ -147,9 +144,12 @@ async function update(backup, data) {
const fields = [], values = [];
for (const p in data) {
if (p === 'label' || p === 'preserveSecs') {
if (p === 'label' || p === 'preserveSecs' || p === 'state') {
fields.push(p + ' = ?');
values.push(data[p]);
} else if (p === 'stats') {
fields.push(`${p}Json=?`);
values.push(JSON.stringify(data[p]));
}
}
values.push(backup.id);
@@ -165,43 +165,13 @@ async function update(backup, data) {
}
}
async function setState(id, state) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof state, 'string');
const result = await database.query('UPDATE backups SET state = ? WHERE id = ?', [state, id]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
}
async function list(page, perPage) {
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups ORDER BY creationTime DESC LIMIT ?,?`, [ (page-1)*perPage, perPage ]);
results.forEach(function (result) { postProcess(result); });
return results;
}
async function listByTypePaged(type, page, perPage) {
async function listByTypePaged(type, siteId, page, perPage) {
assert.strictEqual(typeof type, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE type = ? ORDER BY creationTime DESC LIMIT ?,?`, [ type, (page-1)*perPage, perPage ]);
results.forEach(function (result) { postProcess(result); });
return results;
}
async function listBySiteId(siteId, page, perPage) {
assert.strictEqual(typeof siteId, 'string');
assert(typeof page === 'number' && page > 0);
assert(typeof perPage === 'number' && perPage > 0);
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE siteId=? AND type=? ORDER BY creationTime DESC LIMIT ?,?`, [ siteId, exports.BACKUP_TYPE_BOX, (page-1)*perPage, perPage ]);
const results = await database.query(`SELECT ${BACKUPS_FIELDS} FROM backups WHERE siteId=? AND type = ? ORDER BY creationTime DESC LIMIT ?,?`, [ siteId, type, (page-1)*perPage, perPage ]);
results.forEach(function (result) { postProcess(result); });
+43 -23
View File
@@ -106,11 +106,12 @@ async function upload(remotePath, siteId, dataLayoutString, progressCallback) {
// - tgz: only one entry named "." in the map. fileCount has the file count inside.
// - rsync: entry for each relative path.
// integrity - { signature } of the uploaded .backupinfo .
// stats - { fileCount, size, startTime, totalMsecs, transferred }
// stats - { fileCount, size, transferred }
// - tgz: size (backup size) and transferred is the same
// - rsync: size (final backup size) will be different from what was transferred (only changed files)
// stats.fileCount and stats.size are stored in db and should match up what is written into .backupinfo
const { stats, integrityMap } = await backupFormats.api(backupSite.format).upload(backupSite, remotePath, dataLayout, progressCallback);
debug(`upload: path ${remotePath} site ${siteId} uploaded: ${JSON.stringify(stats)}`);
progressCallback({ message: `Uploading integrity information to ${remotePath}.backupinfo` });
const signature = await uploadBackupInfo(backupSite, remotePath, integrityMap);
@@ -203,7 +204,7 @@ async function runBackupUpload(uploadConfig, progressCallback) {
throw new BoxError(BoxError.EXTERNAL_ERROR, lastMessage.errorMessage);
}
return lastMessage.result;
return lastMessage.result; // { stats, integrity }
}
async function snapshotBox(progressCallback) {
@@ -272,25 +273,27 @@ async function copy(backupSite, srcRemotePath, destRemotePath, progressCallback)
async function backupBox(backupSite, appBackupsMap, tag, options, progressCallback) {
assert.strictEqual(typeof backupSite, 'object');
assert(util.types.isMap(appBackupsMap), 'integrityMap should be a Map'); // id -> stats { fileCount, size, startTime, totalMsecs, transferred }
assert(util.types.isMap(appBackupsMap), 'appBackupsMap should be a Map'); // id -> stats: { upload: { fileCount, size, startTime, duration, transferred } }
assert.strictEqual(typeof tag, 'string');
assert.strictEqual(typeof options, 'object');
assert.strictEqual(typeof progressCallback, 'function');
const { stats, integrity } = await uploadBoxSnapshot(backupSite, progressCallback);
const uploadStartTime = Date.now();
const uploadResult = await uploadBoxSnapshot(backupSite, progressCallback); // { stats, integrity }
const stats = { upload: { ...uploadResult.stats, startTime: uploadStartTime, duration: Date.now() - uploadStartTime } };
const remotePath = addFileExtension(backupSite, `${tag}/box_v${constants.VERSION}`);
debug(`backupBox: rotating box snapshot of ${backupSite.id} to id ${remotePath}`);
// stats object might be null for stopped/errored apps from old versions
stats.aggregated = Array.from(appBackupsMap.values()).filter(s => !!s).reduce((acc, s) => ({
fileCount: acc.fileCount + s.fileCount,
size: acc.size + s.size,
startTime: Math.min(acc.startTime, s.startTime),
totalMsecs: acc.totalMsecs + s.totalMsecs,
transferred: acc.transferred + s.transferred,
}), stats);
stats.aggregatedUpload = Array.from(appBackupsMap.values()).filter(s => !!s?.upload).reduce((acc, cur) => ({
fileCount: acc.fileCount + cur.upload.fileCount,
size: acc.size + cur.upload.size,
transferred: acc.transferred + cur.upload.transferred,
startTime: Math.min(acc.startTime, cur.upload.startTime),
duration: acc.duration + cur.upload.duration,
}), stats.upload);
debug(`backupBox: rotating box snapshot of ${backupSite.id} to id ${remotePath}. ${JSON.stringify(stats)}`);
const data = {
remotePath,
@@ -305,14 +308,23 @@ async function backupBox(backupSite, appBackupsMap, tag, options, progressCallba
appConfig: null,
siteId: backupSite.id,
stats,
integrity
integrity: uploadResult.integrity
};
const id = await backups.add(data);
const snapshotPath = addFileExtension(backupSite, 'snapshot/box');
const copyStartTime = Date.now();
const [error] = await safe(copy(backupSite, snapshotPath, remotePath, progressCallback));
const state = error ? backups.BACKUP_STATE_ERROR : backups.BACKUP_STATE_NORMAL;
await backups.setState(id, state);
if (!error) {
stats.copy = { startTime: copyStartTime, duration: Date.now() - copyStartTime };
// stats object might be null for stopped/errored apps from old versions
stats.aggregatedCopy = Array.from(appBackupsMap.values()).filter(s => !!s).reduce((acc, cur) => ({
startTime: Math.min(acc.startTime, cur.copy.startTime),
duration: acc.duration + cur.copy.duration,
}), stats.copy);
}
await backups.update({ id }, { stats, state });
if (error) throw error;
return id;
@@ -377,7 +389,9 @@ async function backupAppWithTag(app, backupSite, tag, options, progressCallback)
return { id: lastKnownGoodAppBackup.id, stats: lastKnownGoodAppBackup.stats };
}
const { stats, integrity } = await uploadAppSnapshot(backupSite, app, progressCallback);
const uploadStartTime = Date.now();
const uploadResult = await uploadAppSnapshot(backupSite, app, progressCallback); // { stats, integrity }
const stats = { upload: { ...uploadResult.stats, startTime: uploadStartTime, duration: Date.now() - uploadStartTime } };
const manifest = app.manifest;
const remotePath = addFileExtension(backupSite, `${tag}/app_${app.fqdn}_v${manifest.version}`);
@@ -397,17 +411,19 @@ async function backupAppWithTag(app, backupSite, tag, options, progressCallback)
appConfig: app,
siteId: backupSite.id,
stats,
integrity
integrity: uploadResult.integrity
};
const id = await backups.add(data);
const snapshotPath = addFileExtension(backupSite, `snapshot/app_${app.id}`);
const copyStartTime = Date.now();
const [error] = await safe(copy(backupSite, snapshotPath, remotePath, progressCallback));
const state = error ? backups.BACKUP_STATE_ERROR : backups.BACKUP_STATE_NORMAL;
await backups.setState(id, state);
if (!error) stats.copy = { startTime: copyStartTime, duration: Date.now() - copyStartTime };
await backups.update({ id }, { stats, state });
if (error) throw error;
return { id, stats: data.stats };
return { id, stats };
}
async function backupApp(app, backupSite, options, progressCallback) {
@@ -466,7 +482,9 @@ async function backupMailWithTag(backupSite, tag, options, progressCallback) {
debug(`backupMailWithTag: backing up mail with tag ${tag}`);
const { stats, integrity } = await uploadMailSnapshot(backupSite, progressCallback);
const uploadStartTime = Date.now();
const uploadResult = await uploadMailSnapshot(backupSite, progressCallback); // { stats, integrity }
const stats = { upload: { ...uploadResult.stats, startTime: uploadStartTime, duration: Date.now() - uploadStartTime } };
const remotePath = addFileExtension(backupSite, `${tag}/mail_v${constants.VERSION}`);
@@ -485,17 +503,19 @@ async function backupMailWithTag(backupSite, tag, options, progressCallback) {
appConfig: null,
siteId: backupSite.id,
stats,
integrity
integrity: uploadResult.integrity
};
const id = await backups.add(data);
const snapshotPath = addFileExtension(backupSite, 'snapshot/mail');
const copyStartTime = Date.now();
const [error] = await safe(copy(backupSite, snapshotPath, remotePath, progressCallback));
const state = error ? backups.BACKUP_STATE_ERROR : backups.BACKUP_STATE_NORMAL;
await backups.setState(id, state);
if (!error) stats.copy = { startTime: copyStartTime, duration: Date.now() - copyStartTime };
await backups.update({ id }, { stats, state });
if (error) throw error;
return { id, stats: data.stats };
return { id, stats };
}
async function downloadMail(backupSite, remotePath, progressCallback) {
+1
View File
@@ -60,6 +60,7 @@ function api(provider) {
case 'namecheap': return require('./dns/namecheap.js');
case 'netcup': return require('./dns/netcup.js');
case 'hetzner': return require('./dns/hetzner.js');
case 'hetznercloud': return require('./dns/hetznercloud.js');
case 'noop': return require('./dns/noop.js');
case 'manual': return require('./dns/manual.js');
case 'ovh': return require('./dns/ovh.js');
+238
View File
@@ -0,0 +1,238 @@
'use strict';
exports = module.exports = {
removePrivateFields,
injectPrivateFields,
upsert,
get,
del,
wait,
verifyDomainConfig
};
const assert = require('node:assert'),
BoxError = require('../boxerror.js'),
constants = require('../constants.js'),
debug = require('debug')('box:dns/hetznercloud'),
dig = require('../dig.js'),
dns = require('../dns.js'),
promiseRetry = require('../promise-retry.js'),
safe = require('safetydance'),
superagent = require('@cloudron/superagent'),
waitForDns = require('./waitfordns.js');
// https://docs.hetzner.cloud/reference/cloud
const ENDPOINT = 'https://api.hetzner.cloud/v1';
function formatError(response) {
return `Hetzner DNS error ${response.status} ${response.text}`;
}
function removePrivateFields(domainObject) {
delete domainObject.config.token;
return domainObject;
}
function injectPrivateFields(newConfig, currentConfig) {
if (!Object.hasOwn(newConfig, 'token')) newConfig.token = currentConfig.token;
}
async function getZone(domainConfig, zoneName) {
assert.strictEqual(typeof domainConfig, 'object');
assert.strictEqual(typeof zoneName, 'string');
const [error, response] = await safe(superagent.get(`${ENDPOINT}/zones`)
.set('Authorization', `Bearer ${domainConfig.token}`)
.query({ search_name: zoneName })
.timeout(30 * 1000)
.retry(5)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
if (!Array.isArray(response.body.zones)) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
const zone = response.body.zones.find(z => z.name === zoneName);
if (!zone) throw new BoxError(BoxError.NOT_FOUND, formatError(response));
return zone;
}
async function getRecords(domainConfig, zone, name, type) {
assert.strictEqual(typeof domainConfig, 'object');
assert.strictEqual(typeof zone, 'object');
assert.strictEqual(typeof name, 'string');
assert.strictEqual(typeof type, 'string');
debug(`getRecords: getting dns records of ${zone.name} with ${name} and type ${type}`);
const [error, response] = await safe(superagent.get(`${ENDPOINT}/zones/${zone.id}/rrsets/${name}/${type.toUpperCase()}`)
.set('Authorization', `Bearer ${domainConfig.token}`)
.timeout(30 * 1000)
.retry(5)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 404) return [];
if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
return response.body.rrset.records;
}
async function waitForAction(domainConfig, id) {
assert.strictEqual(typeof domainConfig, 'object');
assert.strictEqual(typeof id, 'number');
await promiseRetry({ times: 100, interval: 1000, debug }, async () => {
const [error, response] = await safe(superagent.get(`${ENDPOINT}/actions/${id}`)
.set('Authorization', `Bearer ${domainConfig.token}`)
.timeout(30 * 1000)
.retry(5)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 404) return [];
if (response.status === 401 || response.status === 403) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.status !== 200) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
if (response.body.action.status !== 'success') throw new BoxError(BoxError.TRY_AGAIN, 'action not done yet');
});
}
async function upsert(domainObject, location, type, values) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(Array.isArray(values));
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '@';
debug(`upsert: ${name} for zone ${zoneName} of type ${type} with values ${JSON.stringify(values)}`);
const zone = await getZone(domainConfig, zoneName);
const records = await getRecords(domainConfig, zone, name, type);
// update means first delete then recreate
if (records.length) await del(domainObject, location, type, values);
const data = {
name,
type,
ttl: 60,
records: values.map(v => { return { value: v, comment: 'managed by cloudron' }; }),
labels: {
managedBy: 'cloudron'
}
};
const [error, response] = await safe(superagent.post(`${ENDPOINT}/zones/${zone.id}/rrsets`)
.set('Authorization', `Bearer ${domainConfig.token}`)
.send(data)
.timeout(30 * 1000)
.retry(5)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.status === 422) throw new BoxError(BoxError.BAD_FIELD, response.body.message);
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
await waitForAction(domainConfig, response.body.action.id);
}
async function get(domainObject, location, type) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '@';
const zone = await getZone(domainConfig, zoneName);
const result = await getRecords(domainConfig, zone, name, type);
return result.map(function (record) { return record.value; });
}
async function del(domainObject, location, type, values) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof location, 'string');
assert.strictEqual(typeof type, 'string');
assert(Array.isArray(values));
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName,
name = dns.getName(domainObject, location, type) || '@';
const zone = await getZone(domainConfig, zoneName);
const records = await getRecords(domainConfig, zone, name, type);
if (records.length === 0) return;
const [error, response] = await safe(superagent.del(`${ENDPOINT}/zones/${zone.id}/rrsets/${name}/${type}`)
.set('Authorization', `Bearer ${domainConfig.token}`)
.timeout(30 * 1000)
.retry(5)
.ok(() => true));
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error);
if (response.status === 404) return;
if (response.status === 403 || response.status === 401) throw new BoxError(BoxError.ACCESS_DENIED, formatError(response));
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, formatError(response));
await waitForAction(domainConfig, response.body.action.id);
}
async function wait(domainObject, subdomain, type, value, options) {
assert.strictEqual(typeof domainObject, 'object');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof value, 'string');
assert(options && typeof options === 'object'); // { interval: 5000, times: 50000 }
const fqdn = dns.fqdn(subdomain, domainObject.domain);
await waitForDns(fqdn, domainObject.zoneName, type, value, options);
}
async function verifyDomainConfig(domainObject) {
assert.strictEqual(typeof domainObject, 'object');
const domainConfig = domainObject.config,
zoneName = domainObject.zoneName;
if (!domainConfig.token || typeof domainConfig.token !== 'string') throw new BoxError(BoxError.BAD_FIELD, 'token must be a non-empty string');
if ('customNameservers' in domainConfig && typeof domainConfig.customNameservers !== 'boolean') throw new BoxError(BoxError.BAD_FIELD, 'customNameservers must be a boolean');
const ip = '127.0.0.1';
const credentials = {
token: domainConfig.token,
customNameservers: !!domainConfig.customNameservers
};
if (constants.TEST) return credentials; // this shouldn't be here
const [error, nameservers] = await safe(dig.resolve(zoneName, 'NS', { timeout: 5000 }));
if (error && error.code === 'ENOTFOUND') throw new BoxError(BoxError.BAD_FIELD, 'Unable to resolve nameservers for this domain');
if (error || !nameservers) throw new BoxError(BoxError.BAD_FIELD, error ? error.message : 'Unable to get nameservers');
// https://docs.hetzner.com/dns-console/dns/general/dns-overview#the-hetzner-online-name-servers-are
if (!nameservers.every(function (n) { return n.toLowerCase().search(/hetzner|your-server|second-ns/) !== -1; })) {
debug('verifyDomainConfig: %j does not contain Hetzner NS', nameservers);
if (!domainConfig.customNameservers) throw new BoxError(BoxError.BAD_FIELD, 'Domain nameservers are not set to Hetzner');
}
const location = 'cloudrontestdns';
await upsert(domainObject, location, 'A', [ ip ]);
debug('verifyDomainConfig: Test A record added');
await del(domainObject, location, 'A', [ ip ]);
debug('verifyDomainConfig: Test A record removed again');
return credentials;
}
+1
View File
@@ -65,6 +65,7 @@ function api(provider) {
case 'gandi': return require('./dns/gandi.js');
case 'godaddy': return require('./dns/godaddy.js');
case 'hetzner': return require('./dns/hetzner.js');
case 'hetznercloud': return require('./dns/hetznercloud.js');
case 'inwx': return require('./dns/inwx.js');
case 'linode': return require('./dns/linode.js');
case 'vultr': return require('./dns/vultr.js');
+17 -11
View File
@@ -463,27 +463,27 @@ async function syncGroupMembers(config, progressCallback) {
return [];
}
const allGroups = await groups.list();
const allGroups = await groups.listWithMembers();
const ldapGroups = allGroups.filter(function (g) { return g.source === 'ldap'; });
debug(`syncGroupMembers: Found ${ldapGroups.length} groups to sync users`);
for (const group of ldapGroups) {
debug(`syncGroupMembers: Sync users for group ${group.name}`);
for (const ldapGroup of ldapGroups) {
debug(`syncGroupMembers: Sync users for group ${ldapGroup.name}`);
const result = await ldapGroupSearch(config, {});
if (!result || result.length === 0) {
debug(`syncGroupMembers: Unable to find group ${group.name} ignoring for now.`);
debug(`syncGroupMembers: Unable to find group ${ldapGroup.name} ignoring for now.`);
continue;
}
// since our group names are lowercase we cannot use potentially case matching ldap filters
const found = result.find(function (r) {
if (!r[config.groupnameField]) return false;
return r[config.groupnameField].toLowerCase() === group.name;
return r[config.groupnameField].toLowerCase() === ldapGroup.name;
});
if (!found) {
debug(`syncGroupMembers: Unable to find group ${group.name} ignoring for now.`);
debug(`syncGroupMembers: Unable to find group ${ldapGroup.name} ignoring for now.`);
continue;
}
@@ -492,17 +492,17 @@ async function syncGroupMembers(config, progressCallback) {
// if only one entry is in the group ldap returns a string, not an array!
if (typeof ldapGroupMembers === 'string') ldapGroupMembers = [ ldapGroupMembers ];
debug(`syncGroupMembers: Group ${group.name} has ${ldapGroupMembers.length} members.`);
debug(`syncGroupMembers: Group ${ldapGroup.name} has ${ldapGroupMembers.length} members.`);
const userIds = [];
for (const memberDn of ldapGroupMembers) {
const [ldapError, result] = await safe(ldapGetByDN(config, memberDn));
if (ldapError) {
debug(`syncGroupMembers: Group ${group.name} failed to get ${memberDn}: %o`, ldapError);
debug(`syncGroupMembers: Group ${ldapGroup.name} failed to get ${memberDn}: %o`, ldapError);
continue;
}
debug(`syncGroupMembers: Group ${group.name} has member object ${memberDn}`);
debug(`syncGroupMembers: Group ${ldapGroup.name} has member object ${memberDn}`);
const username = result[config.usernameField]?.toLowerCase();
if (!username) continue;
@@ -515,8 +515,14 @@ async function syncGroupMembers(config, progressCallback) {
userIds.push(userObject.id);
}
const [setError] = await safe(groups.setMembers(group, userIds, { skipSourceCheck: true }, AuditSource.EXTERNAL_LDAP));
if (setError) debug(`syncGroupMembers: Failed to set members of group ${group.name}. %o`, setError);
const membersChanged = ldapGroup.userIds.length !== userIds.length || ldapGroup.userIds.some(id => !userIds.includes(id));
if (membersChanged) {
debug(`syncGroupMembers: Group ${ldapGroup.name} changed.`);
const [setError] = await safe(groups.setMembers(ldapGroup, userIds, { skipSourceCheck: true }, AuditSource.EXTERNAL_LDAP));
if (setError) debug(`syncGroupMembers: Failed to set members of group ${ldapGroup.name}. %o`, setError);
} else {
debug(`syncGroupMembers: Group ${ldapGroup.name} is unchanged.`);
}
}
debug('syncGroupMembers: done');
+8 -3
View File
@@ -90,13 +90,18 @@ async function del(group, auditSource) {
assert.strictEqual(typeof group, 'object');
assert(auditSource && typeof auditSource === 'object');
const arSearch = `JSON_SEARCH(accessRestrictionJson, 'one', ?, NULL, '$.groups')`;
const opSearch = `JSON_SEARCH(operatorsJson, 'one', ?, NULL, '$.groups')`;
const queries = [
{ query: `UPDATE apps SET accessRestrictionJson=JSON_REMOVE(accessRestrictionJson, REPLACE(${arSearch}, '"', '')) WHERE ${arSearch} IS NOT NULL`, args: [ group.id, group.id ] },
{ query: `UPDATE apps SET operatorsJson=JSON_REMOVE(operatorsJson, REPLACE(${opSearch}, '"', '')) WHERE ${opSearch} IS NOT NULL`, args: [ group.id, group.id ] },
{ query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ group.id ] },
{ query: 'DELETE FROM userGroups WHERE id = ?', args: [ group.id ] }
{ query: 'DELETE FROM userGroups WHERE id = ?', args: [ group.id ] }, // keep this the last query as we check affectedRows below
];
const result = await database.transaction(queries);
if (result[1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
if (result[queries.length-1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
await eventlog.add(eventlog.ACTION_GROUP_REMOVE, auditSource, { group });
}
@@ -163,7 +168,7 @@ async function listWithMembers() {
' FROM userGroups LEFT OUTER JOIN groupMembers ON userGroups.id = groupMembers.groupId ' +
' GROUP BY userGroups.id ORDER BY name');
results.forEach(function (result) { result.userIds = result.userIds ? result.userIds.split(',') : [ ]; });
results.forEach(function (result) { result.userIds = result.userIds ? result.userIds.split(',') : []; });
for (const r of results) {
await postProcess(r);
+1 -1
View File
@@ -130,7 +130,7 @@ server {
<% if ( endpoint === 'dashboard' || endpoint === 'ip' || endpoint === 'setup' ) { -%>
# CSP headers for the dashboard resources
add_header Content-Security-Policy "default-src 'none'; frame-src 'self' cloudron.io *.cloudron.io; connect-src wss: https: 'self' *.cloudron.io; script-src https: 'self' 'unsafe-inline' 'unsafe-eval'; media-src *; img-src * blob: data:; style-src https: 'unsafe-inline'; object-src 'none'; font-src https: 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self';";
add_header Content-Security-Policy "default-src 'none'; frame-src 'self' cloudron.io *.cloudron.io; connect-src wss: https: 'self' *.cloudron.io; script-src https: 'self' 'unsafe-inline' 'unsafe-eval'; media-src *; img-src * blob: data:; style-src https: 'unsafe-inline'; object-src 'none'; font-src https: data: 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self';";
<% } else { %>
<% if (cspQuoted) { %>
add_header Content-Security-Policy <%- cspQuoted %>;
+2 -2
View File
@@ -22,8 +22,8 @@ class ProgressStream extends TransformStream {
}
stats() {
const totalMsecs = Date.now() - this.#startTime;
return { startTime: this.#startTime, totalMsecs, transferred: this.#transferred };
const duration = Date.now() - this.#startTime;
return { startTime: this.#startTime, duration, transferred: this.#transferred };
}
_start() {
+1 -1
View File
@@ -266,7 +266,7 @@ async function listBackups(req, res, next) {
const perPage = typeof req.query.per_page === 'string'? parseInt(req.query.per_page) : 25;
if (!perPage || perPage < 0) return next(new HttpError(400, 'per_page query param has to be a postive number'));
const [error, results] = await safe(backups.listBySiteId(req.resources.backupSite.id, page, perPage));
const [error, results] = await safe(backups.listByTypePaged(backups.BACKUP_TYPE_BOX, req.resources.backupSite.id, page, perPage));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(200, { backups: results }));
}
+5 -5
View File
@@ -114,9 +114,9 @@ async function getFilesystems() {
}
const standardPaths = [
{ type: 'standard', id: 'platformdata', name: 'Platform Data', path: paths.PLATFORM_DATA_DIR },
{ type: 'standard', id: 'boxdata', name: 'Box Data', path: paths.BOX_DATA_DIR },
{ type: 'standard', id: 'maildata', name: 'Mail Data', path: paths.MAIL_DATA_DIR },
{ type: 'standard', id: 'platformdata', name: 'Platform data', path: paths.PLATFORM_DATA_DIR },
{ type: 'standard', id: 'boxdata', name: 'Box data', path: paths.BOX_DATA_DIR },
{ type: 'standard', id: 'maildata', name: 'Mail data', path: paths.MAIL_DATA_DIR },
];
for (const stdPath of standardPaths) {
@@ -140,7 +140,7 @@ async function getFilesystems() {
if (!siteForDefault) {
const [, dfResult] = await safe(df.file(paths.DEFAULT_BACKUP_DIR));
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
if (filesystems[filesystem]) filesystems[filesystem].contents.push({ type: 'cloudron-backup-default', id: 'cloudron-backup-default', name: 'Cloudron Default Backup', path: paths.DEFAULT_BACKUP_DIR });
if (filesystems[filesystem]) filesystems[filesystem].contents.push({ type: 'cloudron-backup-default', id: 'cloudron-backup-default', name: 'Default backup', path: paths.DEFAULT_BACKUP_DIR });
}
const [dockerError, dockerInfo] = await safe(docker.info());
@@ -149,7 +149,7 @@ async function getFilesystems() {
const filesystem = dfResult?.filesystem || rootDisk.filesystem;
if (filesystems[filesystem]) {
filesystems[filesystem].contents.push({ type: 'standard', id: 'docker', name: 'Docker', path: dockerInfo.DockerRootDir });
filesystems[filesystem].contents.push({ type: 'standard', id: 'docker-volumes', name: 'Docker Volumes', path: dockerInfo.DockerRootDir });
filesystems[filesystem].contents.push({ type: 'standard', id: 'docker-volumes', name: 'Docker volumes', path: dockerInfo.DockerRootDir });
}
}
+3 -3
View File
@@ -288,7 +288,7 @@ describe('backup cleaner', function () {
it('succeeds with box backups, keeps latest', async function () {
await cleanupBackups(site);
const results = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, 1, 1000);
const results = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, site.id, 1, 1000);
expect(results.length).to.equal(1);
expect(results[0].id).to.equal(BACKUP_1_BOX.id);
@@ -300,7 +300,7 @@ describe('backup cleaner', function () {
it('does not remove expired backups if only one left', async function () {
await cleanupBackups(site);
const results = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, 1, 1000);
const results = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, site.id, 1, 1000);
expect(results[0].id).to.equal(BACKUP_1_BOX.id);
// check that app backups are also still there. backup_1 is still there
@@ -318,7 +318,7 @@ describe('backup cleaner', function () {
await cleanupBackups(site);
let result = await backups.listByTypePaged(backups.BACKUP_TYPE_APP, 1, 1000);
let result = await backups.listByTypePaged(backups.BACKUP_TYPE_APP, site.id, 1, 1000);
expect(result.length).to.equal(4);
result = result.sort((r1, r2) => r1.remotePath.localeCompare(r2.remotePath));
expect(result[0].id).to.be(BACKUP_0_APP_0.id); // because app is installed, latest backup is preserved
+1 -1
View File
@@ -83,7 +83,7 @@ describe('backups', function () {
});
it('listByTypePaged succeeds', async function () {
const results = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, 1, 5);
const results = await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, defaultBackupSite.id, 1, 5);
expect(results.length).to.be(1);
delete results[0].creationTime;
expect(results[0]).to.eql(boxBackup);
+1 -1
View File
@@ -130,7 +130,7 @@ describe('backups', function () {
backupSite.integrityKeyPair = {}; // keep removePrivateFields happy
await backupSites.del(backupSite, auditSource);
expect(await backups.list(1, 10)).to.eql([]);
expect(await backups.listByTypePaged(backups.BACKUP_TYPE_BOX, defaultBackupSite.id, 1, 10)).to.eql([]);
expect(await archives.list(1, 10)).to.eql([]);
expect(await backupSites.list()).to.eql([]);
});
+12 -6
View File
@@ -316,16 +316,22 @@ async function del(user, auditSource) {
if (constants.DEMO && user.username === constants.DEMO_USERNAME) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
const queries = [];
queries.push({ query: 'DELETE FROM groupMembers WHERE userId = ?', args: [ user.id ] });
queries.push({ query: 'DELETE FROM tokens WHERE identifier = ?', args: [ user.id ] });
queries.push({ query: 'DELETE FROM appPasswords WHERE userId = ?', args: [ user.id ] });
queries.push({ query: 'DELETE FROM users WHERE id = ?', args: [ user.id ] });
const arSearch = `JSON_SEARCH(accessRestrictionJson, 'one', ?, NULL, '$.users')`;
const opSearch = `JSON_SEARCH(operatorsJson, 'one', ?, NULL, '$.users')`;
const queries = [
{ query: `UPDATE apps SET accessRestrictionJson=JSON_REMOVE(accessRestrictionJson, REPLACE(${arSearch}, '"', '')) WHERE ${arSearch} IS NOT NULL`, args: [ user.id, user.id ] },
{ query: `UPDATE apps SET operatorsJson=JSON_REMOVE(operatorsJson, REPLACE(${opSearch}, '"', '')) WHERE ${opSearch} IS NOT NULL`, args: [ user.id, user.id ] },
{ query: 'DELETE FROM groupMembers WHERE userId = ?', args: [ user.id ] },
{ query: 'DELETE FROM tokens WHERE identifier = ?', args: [ user.id ] },
{ query: 'DELETE FROM appPasswords WHERE userId = ?', args: [ user.id ] },
{ query: 'DELETE FROM users WHERE id = ?', args: [ user.id ] }, // keep this the last query as we check affectedRows below
];
const [error, result] = await safe(database.transaction(queries));
if (error && error.sqlCode === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, error);
if (error) throw error;
if (result[3].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
if (result[queries.length-1].affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
await eventlog.add(eventlog.ACTION_USER_REMOVE, auditSource, { userId: user.id, user: removePrivateFields(user) });
}