Compare commits

..

120 Commits

Author SHA1 Message Date
Girish Ramakrishnan 1232b30e29 More 9.0.9 changes 2025-11-13 15:31:27 +01:00
Girish Ramakrishnan 03aae46880 update: show update error 2025-11-13 15:05:59 +01:00
Girish Ramakrishnan 25ce947df5 access control: always show the user management section 2025-11-13 14:42:44 +01:00
Girish Ramakrishnan b8f486d8e4 backuptask: fix crash when (old) stats object has no copy field 2025-11-13 14:42:44 +01:00
Girish Ramakrishnan 6305ff7410 incoming mail: remove cloudflare warning, will make this a check 2025-11-13 13:19:33 +01:00
Girish Ramakrishnan b2941894cd Fix amdinDomain not passed to the MailRelaySettingsItem 2025-11-13 12:59:02 +01:00
Johannes Zellner 83056519ec fs.existsSync always returns a boolean and does not throw 2025-11-13 12:26:51 +01:00
Johannes Zellner 3cdfbbac56 Fix volume dialog form validation 2025-11-13 12:11:35 +01:00
Girish Ramakrishnan f61e85c2d6 Fix ldap server translations 2025-11-13 11:55:28 +01:00
Girish Ramakrishnan 217ebf8c33 i18n: show which string is bombing 2025-11-13 11:33:40 +01:00
Girish Ramakrishnan b32114f2f2 backup site: fix translations 2025-11-13 11:33:40 +01:00
Johannes Zellner 6209cdbe0e Add api token dialog can only be submitted if name is given 2025-11-13 11:26:59 +01:00
Johannes Zellner afde81ef3e Use a temporary identity file for remote ssh copy 2025-11-13 10:27:33 +01:00
Johannes Zellner fbbd71e7f2 validate functions are not async 2025-11-13 10:09:34 +01:00
Johannes Zellner 54cf168b4d Remove removeCacheFiles() backup sites are immutable now 2025-11-13 10:08:33 +01:00
Girish Ramakrishnan c25b14976c Fix title of uninstall and archive dialog 2025-11-13 09:23:30 +01:00
Girish Ramakrishnan 39c68075fb Use sentence case whenever possible 2025-11-13 09:12:42 +01:00
Girish Ramakrishnan ce15958a9a minio: fix issue with accepting selfsigned certs 2025-11-12 14:18:34 +01:00
Girish Ramakrishnan 8d06defbcb update dialog: fix translations 2025-11-12 12:50:53 +01:00
Girish Ramakrishnan 0d807a37d6 applink: fix button text in edit mode 2025-11-12 12:14:44 +01:00
Girish Ramakrishnan 9a0a2d84da Fix test of unlink account dialog 2025-11-12 12:08:21 +01:00
Girish Ramakrishnan 29e2be47d0 password reset: show error message if any 2025-11-12 11:55:29 +01:00
Johannes Zellner b2e1f66dbb Fix opening app link edit dialog in app list view 2025-11-12 10:22:33 +01:00
Girish Ramakrishnan bfe9ee457d Fix formatting for plural 2025-11-12 09:00:16 +01:00
Girish Ramakrishnan a034b70449 More translation updates 2025-11-11 23:44:42 +01:00
Johannes Zellner 4226654772 Fixup access control component to cover all cases 2025-11-11 19:40:07 +01:00
Johannes Zellner 4ea8ab08a3 Only allow service configuration once we have fetched all service states 2025-11-11 18:18:50 +01:00
Johannes Zellner 702fc120af Actually setr the defaultMemoryLimit from the service 2025-11-11 18:01:04 +01:00
Johannes Zellner 9453084481 Update translations 2025-11-11 17:45:51 +01:00
Girish Ramakrishnan c6dbbc4135 services: edit -> configure 2025-11-11 17:09:10 +01:00
Girish Ramakrishnan ddc53bcb6f app: set eventlog header style like in other views 2025-11-11 16:48:17 +01:00
Girish Ramakrishnan e50509ac45 Translation updates 2025-11-11 16:39:13 +01:00
Girish Ramakrishnan 2ddba469b2 9.0.8 changelog 2025-11-11 09:21:39 +01:00
Girish Ramakrishnan 4e1b2ccbaa dashboard module updates 2025-11-11 09:01:28 +01:00
Girish Ramakrishnan e0b8a2400a Update marked 2025-11-11 08:59:57 +01:00
Girish Ramakrishnan 151ba569a7 Update pankow and friends 2025-11-11 08:59:12 +01:00
Johannes Zellner 2cb755fe44 Format ssh private key on input 2025-11-10 17:25:38 +01:00
Girish Ramakrishnan eeef49fd19 email: fix masquerade toggle 2025-11-10 17:13:58 +01:00
Girish Ramakrishnan 6b2626120c Translation fixes 2025-11-10 16:19:06 +01:00
Johannes Zellner e77ab26516 Update pankow 2025-11-10 15:52:03 +01:00
Johannes Zellner dbaf6c6ce2 Use full URLs for page preview icons and favicon 2025-11-10 15:21:22 +01:00
Johannes Zellner 5e295f9f1e Cloudron avatar URL comes from the meta header 2025-11-10 15:21:22 +01:00
Girish Ramakrishnan 8d3b655517 Fix incorrect padding 2025-11-10 13:30:39 +01:00
Girish Ramakrishnan 64cefd52c8 search: fix domain search to include redirect/alias/secondary domains 2025-11-10 13:30:39 +01:00
Johannes Zellner edb92ed0a5 ImagePicker should always return a png data url 2025-11-10 11:53:40 +01:00
Girish Ramakrishnan a8513cc0fa search: also search in manifest title 2025-11-10 11:26:51 +01:00
Girish Ramakrishnan 20d4ce6632 add fsused to block_devices output 2025-11-10 11:01:19 +01:00
Girish Ramakrishnan d8c3ce30ca lint 2025-11-10 10:27:24 +01:00
Girish Ramakrishnan d894de0784 cloudflare: ensure defaultProxyStatus in older configs
in Cloudron 9, we introduced an automated domain credentials check.
when checking with older cloudflare configs, this fails.
2025-11-10 10:18:32 +01:00
Girish Ramakrishnan 572bd19df6 Yet more translation fixes 2025-11-07 19:03:07 +01:00
Girish Ramakrishnan 4fd399eae9 Fix dialog titles 2025-11-07 17:49:51 +01:00
Johannes Zellner f7f55710d1 Do not share relay provider setting with view and form
Fixes #866
2025-11-07 13:11:07 +01:00
Johannes Zellner 18815b97ce Explicitly define busy ref in EmailDomainsView 2025-11-07 12:46:04 +01:00
Johannes Zellner c4fce32a6a Fix warning as ClipboardAction needs a string as value 2025-11-07 12:11:01 +01:00
Girish Ramakrishnan 9ed5f43ea1 More translation fixes 2025-11-07 12:09:38 +01:00
Johannes Zellner 232bce0a2d Fix size props in ImagePicker 2025-11-07 12:04:48 +01:00
Johannes Zellner 27f975f3c5 Ensure we pass users and groups to the AccessControl component 2025-11-07 11:03:02 +01:00
Girish Ramakrishnan 5b834b4396 user add: hide active checkbox 2025-11-07 10:15:22 +01:00
Girish Ramakrishnan 52b46e2b3e Fix typo that allowed primary domain to be deleted 2025-11-07 09:44:06 +01:00
Girish Ramakrishnan 044fb72da9 change placeholder as helper-text 2025-11-07 09:41:04 +01:00
Girish Ramakrishnan 0cf911bcdd more translation fixes 2025-11-07 09:08:56 +01:00
Girish Ramakrishnan 829512dd13 Fix tests 2025-11-06 18:01:35 +01:00
Johannes Zellner fa886c71b8 Avoid overflowing when textarea does not fit but also don't break lines 2025-11-06 16:50:45 +01:00
Johannes Zellner 21191bdc50 Give sshfs identity files unique filenames across mounts
If the same host was mounted as volume and backup or as a temporary
backup import, sharing the filename of the identify file would mean it
will get removed while still in use
2025-11-06 16:25:06 +01:00
Johannes Zellner 1bf2fe16a2 Fix AppImport dialog prefill from config to match BackupProviderForm inputs 2025-11-06 14:35:12 +01:00
Johannes Zellner c35543af92 Fix mailbox usage and quota sorting 2025-11-06 13:51:39 +01:00
Johannes Zellner 9bb71bd066 helpPopover is not notificationPopover 2025-11-06 12:30:16 +01:00
Girish Ramakrishnan f24e4f291d remove fullstops in some phrases 2025-11-06 11:37:29 +01:00
Girish Ramakrishnan 32ab9a9d32 location: fix various spacing issues 2025-11-06 11:36:58 +01:00
Girish Ramakrishnan 8b520dec48 portbindings: only show portCount when > 1 2025-11-06 10:31:42 +01:00
Girish Ramakrishnan 70c539ac4d mounts: remove loopback type
this is left over code from trying to implement size restricted data dir
2025-11-05 18:29:47 +01:00
Johannes Zellner 610651066a Fix tgz app backup download
Fixes #868
2025-11-05 18:14:48 +01:00
Johannes Zellner aaa750dbbc email eventlog only has 5 columns 2025-11-05 17:55:11 +01:00
Girish Ramakrishnan a518ee83cc backups: show same filesystem warning
fixes #867
2025-11-05 16:58:22 +01:00
Girish Ramakrishnan de84b5113c mounts: always return message when getting status 2025-11-05 16:52:32 +01:00
Girish Ramakrishnan 2ea7847d4f Add explicit option to disable automatic backups
Fixes #869
2025-11-05 15:51:15 +01:00
Girish Ramakrishnan 0650fca1cf Add description tag 2025-11-05 15:39:07 +01:00
Girish Ramakrishnan 1b5bd0d379 Enclose form in FormGroup 2025-11-05 15:36:55 +01:00
Girish Ramakrishnan 5b6f796606 Rename BackupScheduleDialog.vue to BackupSiteScheduleDialog.vue 2025-11-05 13:41:13 +01:00
Girish Ramakrishnan 9d6a755486 backup site: make config the first option 2025-11-05 13:37:59 +01:00
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
125 changed files with 3301 additions and 2819 deletions
+40
View File
@@ -3016,3 +3016,43 @@
* 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
[9.0.8]
* Add explicit option to disable automatic backups
* backups: show same filesystem warning
* Fix tgz app backup download
* Fix mailbox usage and quota sorting
* Give sshfs identity files unique filenames across mounts
* Do not share relay provider setting with view and form
* cloudflare: ensure defaultProxyStatus in older configs
* filter: fix domain search to include redirect/alias/secondary domains
* Use full URLs for page preview icons and favicon
* email: fix masquerade toggle
[9.0.9]
* minio: fix issue with accepting selfsigned certs
* applink: fix button text in edit mode
* password reset: show error message if any
* sshfs: use a temporary identity file for remote ssh copy
* access control: always show the user management section
* update: show the last update error, if any
+99
View File
@@ -0,0 +1,99 @@
## Translations
This documents the convention used for the text in the UI.
### Tale of Two Cases
**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.
### UI Conventions
Keeping as much as possible in Sentence Case helps in sharing the same strings.
| Element | Recommended Style | Example |
| -------------- | ---------------------- | -------------------------------- |
| Headings | Title Case | Manage Account |
| Sub heading | Title Case | Create Admin Account |
| Section/Card | Title Case | System Information |
| Form Labels | Sentence case | Email address |
| Form Groups | Sentence case | Volume mounts, Data directory |
| Table headings | Sentence case | Memory limit |
| Info sections | Sentence case | Cloudron version |
| Buttons | Sentence case | Save changes |
| Radio Buttons | Sentence case | Option one / Option two |
| Checkbox | Sentence case | Use CIFS encryption |
| 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. |
| Placeholders | Sentence case | Comma separated IPs or subnets |
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.
instructional heading in dialogs (like the object being configured) should not have a full stop.
Switch UI description does not have a fullstop.
Setting item description does not need a fullstop (usually).
Checkbox labels do not have a full stop at the end.
No full stop → short labels, commands, headings, or action text (“Configure Service {{serviceName}}”).
Full stop → descriptive text or sentences explaining a setting (“The IPv4 address used for DNS A records.”).
### Dialog Buttons
'Add' for addition
'Cancel' to cancel
'Save' for edit/update
'Remove' for non-destructive/less destructive things (app password remove)
'Delete' for destructive (user delete)
'Close' - Only for dialogs with the only button
### Dialog Text
When asking for confirmation simply ask 'Remove app password "xxx"' . Don't use "really"
or other emotional terms. Quote the password/domain name.
In general, we put just "Delete User" in Title and provide the username in the context.
Title = action (what youre doing)
Description = context (to whom it applies)
### Description Text
| Context | Verb form | Example |
| --------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
| **Action / Button / Instruction** | **Imperative** → “Add” | Button: **Add**, Tooltip: “Add a new link” |
| **Section / View description** | **Imperative** → “Add” | Description: **Adds shortcuts to external services on the dashboard.** |
We use plural when possible. "Admins can ..." , "Operators can ..."
+204 -204
View File
@@ -6,7 +6,7 @@
"packages": {
"": {
"dependencies": {
"@cloudron/pankow": "^3.5.4",
"@cloudron/pankow": "^3.5.9",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
"@vitejs/plugin-vue": "^6.0.1",
@@ -17,14 +17,14 @@
"async": "^3.2.6",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1",
"marked": "^16.4.1",
"marked": "^17.0.0",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"vite": "^7.1.10",
"vite": "^7.2.2",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.22",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
"vue-router": "^4.6.3"
}
@@ -39,21 +39,21 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.4"
"@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -63,22 +63,22 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@cloudron/pankow": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.4.tgz",
"integrity": "sha512-TcyTfO6BkgsWX+FlR6kYr61T9qVjtLgfemAmSuwkeI6U3UeXbEYFqLhbG0Z8kTgYlX1wP03SN0Hl152BQn+cOg==",
"version": "3.5.9",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.9.tgz",
"integrity": "sha512-59aAGwAdOGwSi3csh+jf6+cOEB5IyJrgppooyfj8K031Go145CmN3rD7J1eeVhZRDBa9zjbHNTSqs/rMqkwyEA==",
"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.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"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": {
@@ -1048,53 +1048,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/shared": "3.5.22",
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.24",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-core": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/compiler-core": "3.5.22",
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22",
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.24",
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.19",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-dom": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/devtools-api": {
@@ -1103,53 +1103,53 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/reactivity": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.22"
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
"@vue/shared": "3.5.22",
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
"@vue/shared": "3.5.24",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
},
"peerDependencies": {
"vue": "3.5.22"
"vue": "3.5.24"
}
},
"node_modules/@vue/shared": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
"license": "MIT"
},
"node_modules/@xterm/addon-attach": {
@@ -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.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"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.1",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -1917,18 +1917,18 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz",
"integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==",
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.0.tgz",
"integrity": "sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -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.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -2502,16 +2502,16 @@
}
},
"node_modules/vue": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",
"@vue/runtime-dom": "3.5.22",
"@vue/server-renderer": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "3.5.24",
"@vue/runtime-dom": "3.5.24",
"@vue/server-renderer": "3.5.24",
"@vue/shared": "3.5.24"
},
"peerDependencies": {
"typescript": "*"
@@ -2632,31 +2632,31 @@
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="
},
"@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
},
"@babel/parser": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"requires": {
"@babel/types": "^7.28.4"
"@babel/types": "^7.28.5"
}
},
"@babel/types": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"requires": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
"@babel/helper-validator-identifier": "^7.28.5"
}
},
"@cloudron/pankow": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.4.tgz",
"integrity": "sha512-TcyTfO6BkgsWX+FlR6kYr61T9qVjtLgfemAmSuwkeI6U3UeXbEYFqLhbG0Z8kTgYlX1wP03SN0Hl152BQn+cOg==",
"version": "3.5.9",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.9.tgz",
"integrity": "sha512-59aAGwAdOGwSi3csh+jf6+cOEB5IyJrgppooyfj8K031Go145CmN3rD7J1eeVhZRDBa9zjbHNTSqs/rMqkwyEA==",
"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.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="
},
"@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"
}
},
@@ -3113,49 +3113,49 @@
}
},
"@vue/compiler-core": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
"requires": {
"@babel/parser": "^7.28.4",
"@vue/shared": "3.5.22",
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.24",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"@vue/compiler-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
"requires": {
"@vue/compiler-core": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-core": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"@vue/compiler-sfc": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
"requires": {
"@babel/parser": "^7.28.4",
"@vue/compiler-core": "3.5.22",
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22",
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.24",
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.19",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
"@vue/compiler-ssr": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
"requires": {
"@vue/compiler-dom": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-dom": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"@vue/devtools-api": {
@@ -3164,46 +3164,46 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"@vue/reactivity": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"requires": {
"@vue/shared": "3.5.22"
"@vue/shared": "3.5.24"
}
},
"@vue/runtime-core": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"requires": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"@vue/runtime-dom": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"requires": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
"@vue/shared": "3.5.22",
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
"@vue/shared": "3.5.24",
"csstype": "^3.1.3"
}
},
"@vue/server-renderer": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"requires": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"@vue/shared": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w=="
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A=="
},
"@xterm/addon-attach": {
"version": "0.11.0",
@@ -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.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"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.1",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -3716,17 +3716,17 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"requires": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"marked": {
"version": "16.4.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz",
"integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg=="
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.0.tgz",
"integrity": "sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg=="
},
"micromatch": {
"version": "4.0.8",
@@ -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.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"requires": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4042,15 +4042,15 @@
}
},
"vue": {
"version": "3.5.22",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"requires": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",
"@vue/runtime-dom": "3.5.22",
"@vue/server-renderer": "3.5.22",
"@vue/shared": "3.5.22"
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "3.5.24",
"@vue/runtime-dom": "3.5.24",
"@vue/server-renderer": "3.5.24",
"@vue/shared": "3.5.24"
}
},
"vue-eslint-parser": {
+5 -5
View File
@@ -7,7 +7,7 @@
},
"type": "module",
"dependencies": {
"@cloudron/pankow": "^3.5.4",
"@cloudron/pankow": "^3.5.9",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
"@vitejs/plugin-vue": "^6.0.1",
@@ -18,14 +18,14 @@
"async": "^3.2.6",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.5.1",
"marked": "^16.4.1",
"marked": "^17.0.0",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"vite": "^7.1.10",
"vite": "^7.2.2",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.22",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
"vue-router": "^4.6.3"
}
+2 -16
View File
@@ -3,7 +3,7 @@
"title": "Mine apps",
"noApps": {
"title": "Ingen apps er installeret endnu!",
"description": "Hvad med at installere nogle? Tjek den <a href=\"{{ appStoreLink }}\">App Store</a>"
"description": "Hvad med at installere nogle? Tjek den <a href=\"{{ appStoreLink }}\">App Store</a>."
},
"noAccess": {
"title": "Du har ikke adgang til nogen apps endnu.",
@@ -259,15 +259,13 @@
"title": "Opret app-adgangskode",
"name": "Adgangskode Navn",
"app": "APp",
"copyNow": "Kopier venligst adgangskoden nu. Det vil ikke blive vist igen af sikkerhedshensyn.",
"generatePassword": "Generer adgangskode"
"copyNow": "Kopier venligst adgangskoden nu. Det vil ikke blive vist igen af sikkerhedshensyn."
},
"createApiToken": {
"copyNow": "Kopier venligst API-tokenet nu. Det vil ikke blive vist igen af sikkerhedshensyn.",
"title": "Opret API-token",
"name": "API-token-navn",
"description": "Nyt API-token:",
"generateToken": "Generer API-token",
"access": "API-adgang"
},
"title": "Profil",
@@ -337,9 +335,7 @@
"version": "Version"
},
"configureBackupSchedule": {
"scheduleDescription": "Vælg de dage og timer, hvor Cloudron skal tage backup. Pas på, at denne tidsplan ikke overlapper med <a href=\"/#/settings\">opdateringsplan</a>.",
"title": "Konfigurer tidsplan og opbevaring af sikkerhedskopier",
"schedule": "Tidsplan",
"days": "Dage",
"hours": "Timer",
"retentionPolicy": "Politik for opbevaring"
@@ -375,7 +371,6 @@
"uploadConcurrencyDescription": "Antal filer, der skal uploades parallelt ved sikkerhedskopiering",
"copyConcurrency": "Kopiering af samtidighed",
"copyConcurrencyDescription": "Antal eksterne filkopieringer parallelt ved sikkerhedskopiering.",
"copyConcurrencyDigitalOceanNote": "DigitalOcean Spaces hastighedsgrænser på 20.",
"encryptionPasswordRepeat": "Gentag adgangskode",
"server": "Server IP eller værtsnavn",
"remoteDirectory": "Fjernkatalog",
@@ -540,7 +535,6 @@
},
"services": {
"configure": {
"recoveryModeDescription": "Hvis tjenesten konstant genstartes eller ikke reagerer på grund af datakorruption, skal du sætte tjenesten i genoprettelsestilstand. Brug følgende <a href=\"{{ docsLink }}\" target=\"_blank\">instruktioner</a> for at få tjenesten til at køre igen.",
"title": "Konfigurer {{ name }}",
"resetToDefaults": "Nulstil til standard",
"enableRecoveryMode": "Aktiver genoprettelsestilstand"
@@ -636,7 +630,6 @@
"renewAllAction": "Forny alle certs"
},
"domainDialog": {
"addDescription": "Når du tilføjer et domæne, kan du installere apps på underdomæner til dette domæne. E-mail-indstillingerne for domænet kan konfigureres i visningen Email.",
"wildcardInfo": "Opsætning<i>A</i>records for <b>*.{{ domain }}.</b>og<b>{ domain }}.</b>til denne servers IP.",
"wellKnownDescription": "Værdierne vil blive brugt af Cloudron til at svare på <code>/.well-known/</code> URL'er. Bemærk, at en app skal være tilgængelig på det nøgne domæne <code>{{{ domæne }}</code> for at dette kan fungere. Se <a href=\"{{docsLink}}}\" target=\"_blank\">docs</a> for flere oplysninger.",
"addTitle": "Tilføj domæne",
@@ -883,7 +876,6 @@
},
"enableEmailDialog": {
"description": "Dette vil konfigurere Cloudron til at modtage e-mails for<b>{{ domain }}</b>Se dokumentationen for åbning af de <a href=\"{{{ requiredPortsDocsLink }}\" target=\"_blank\">forpligtede porte</a> for Cloudron Email.",
"cloudflareInfo": "Mailserverens domæne <code>{{ adminDomain }}</code> administreres af Cloudflare. Kontrollér, at Cloudflare-proxy er deaktiveret for <code>{{ mailFqdn }}</code> og indstillet til <code>kun DNS</code>. Dette er nødvendigt, fordi Cloudflare ikke proxy'er e-mail.",
"title": "Aktiver e-mail for {{ domain }}?",
"noProviderInfo": "Der er ikke oprettet nogen DNS-udbyder. De DNS-poster, der er anført i fanen Status, skal oprettes manuelt.",
"setupDnsCheckbox": "Opsæt Mail DNS-poster nu",
@@ -907,10 +899,6 @@
"title": "E-mail-konfiguration {{ domain }}",
"clientConfiguration": "Konfigurering af e-mail-klienter"
},
"masquerading": {
"title": "Masquerading",
"description": "Maskerading gør det muligt for brugere og apps at sende e-mails med et vilkårligt brugernavn i FROM-adressen."
},
"dnsStatus": {
"description": "Status for DNS-optegnelser kan vise en fejl, mens DNS-forplantningen foregår (~5 minutter). Se den<a href=\"{{ emailDnsDocsLink }}\" target=\"_blank\">troubleshooting</a> for at få hjælp.",
"namecheapInfo": "Namecheap kræver manuelle trin for MX-poster",
@@ -1168,7 +1156,6 @@
},
"robots": {
"title": "Robots.txt",
"txtPlaceholder": "Lad den være tom for at tillade alle robotter at indeksere denne app",
"disableIndexingAction": "Deaktivere indeksering"
},
"hstsPreload": "Aktiver HSTS-forudindlæsning for dette websted og alle underdomæner"
@@ -1180,7 +1167,6 @@
},
"importBackupDialog": {
"title": "Import af sikkerhedskopiering",
"description": "Alle data, der er genereret mellem nu og den sidst kendte sikkerhedskopi, vil uigenkaldeligt gå tabt. Det anbefales at oprette en sikkerhedskopi af de aktuelle data, før du forsøger at importere dem.",
"uploadAction": "Upload backup-konfiguration",
"importAction": "Import",
"remotePath": "Sikkerhedskopieringssti"
+1 -18
View File
@@ -356,7 +356,6 @@
"disable2FAAction": "2FA deaktivieren",
"changePasswordAction": "Passwort ändern",
"createApiToken": {
"generateToken": "API-Token generieren",
"copyNow": "API-Token kopieren. Hinweis: keine erneute Anzeige des API-Tokens.",
"description": "Neuer API-Token:",
"name": "Name des API-Token",
@@ -365,7 +364,6 @@
"allowedIpRanges": "Erlaubte IP-Bereich(e)"
},
"createAppPassword": {
"generatePassword": "Passwort generieren",
"copyNow": "Hinweis: das Passwort wird nicht erneut angezeigt. Bitte Passwort kopieren.",
"description": "Folgendes Passwort wurde generiert und ist für die App gültig:",
"app": "Anwendung",
@@ -529,7 +527,6 @@
"gcdnsServiceAccountKey": "Service-Kontoschlüssel",
"cloudflareTokenTypeGlobalApiKey": "Globaler API-Schlüssel",
"editTitle": "{{ domain }} konfigurieren",
"addDescription": "Durch das Hinzufügen einer Domäne können Anwendungen auf Unterdomänen dieser Domäne installiert werden. E-Mail-Einstellungen für die Domäne können in der Ansicht E-Mail konfiguriert werden.",
"domain": "Domäne",
"provider": "DNS-Anbieter",
"gandiApiKey": "Gandi-API-Key",
@@ -669,7 +666,6 @@
"title": "Backup-Speicher konfigurieren",
"encryptionPasswordRepeat": "Password wiederholen",
"encryptionPasswordPlaceholder": "Zur Verschlüsselung der Sicherungen verwendete Passphrase",
"copyConcurrencyDigitalOceanNote": "Das Limit von DigitalOcean Spaces liegt bei 20.",
"copyConcurrencyDescription": "Anzahl der Remote-Dateikopien, die parallel bei einem Backup genutzt werden.",
"copyConcurrency": "Gleichzeitige Zugriffe beim kopieren",
"uploadConcurrencyDescription": "Anzahl der Dateien, die beim Backup parallel hochgeladen werden",
@@ -711,8 +707,6 @@
"retentionPolicy": "Aufbewahrungsrichtlinie",
"hours": "Stunden",
"days": "Tage",
"scheduleDescription": "Tage und Stunden auswählen, an denen Cloudron ein Backup erstellen soll. Der Zeitplan soll sich nicht mit dem <a href=\"/#/settings\">Zeitplan für Aktualisierungen</a> überschneiden.",
"schedule": "Zeitplan",
"title": "Sicherungszeitplan und Aufbewahrung konfigurieren"
},
"backupDetails": {
@@ -827,8 +821,7 @@
"configure": {
"title": "{{ name }} konfigurieren",
"resetToDefaults": "Auf Standardwert zurücksetzen",
"enableRecoveryMode": "Wiederherstellungsmodus aktivieren",
"recoveryModeDescription": "Wenn eine App ständig neu gestartet wird oder aufgrund einer Datenbeschädigung nicht reagiert, schalten Sie die App in den Wiederherstellungsmodus. Verwenden Sie die folgenden <a href=\"{{ docsLink }}\" target=\"_blank\">Anweisungen</a>, um die App wieder zum Laufen zu bringen."
"enableRecoveryMode": "Wiederherstellungsmodus aktivieren"
},
"restartActionTooltip": "Neustart"
},
@@ -927,10 +920,6 @@
"incomingUserInfo": "Benutzername",
"description": "Eingehende E-Mails für diese Domäne empfangen."
},
"masquerading": {
"description": "Maskierung erlaubt es Usern und Anwendungen, E-Mails mit einem beliebigen Username in der FROM-Adresse zu versenden.",
"title": "Maskierung"
},
"smtpStatus": {
"notBlacklisted": "Die IP-Adresse des Servers {{ ip }} ist <b>nicht</b> auf einer bekannten Blockliste.",
"blacklisted": "Die IP-Adresse des Servers {{ ip }} ist auf einer Blockliste.",
@@ -940,7 +929,6 @@
"enableEmailDialog": {
"description": "Dies wird Cloudron so konfigurieren, dass E-Mails für <b>{{ domain }}</b> empfangen werden. Die Dokumentation zum Öffnen der <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">erforderlichen Ports</a> für Cloudron E-Mail lesen.",
"noProviderInfo": "Es ist kein DNS-Anbieter eingerichtet. Die in der Registerkarte Status aufgeführten DNS-Einträge müssen manuell eingerichtet werden.",
"cloudflareInfo": "Die E-Mail Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"enableAction": "Aktivieren",
"title": "E-Mail für {{ domain }} aktivieren?",
"setupDnsCheckbox": "DNS-Einträge für E-Mail jetzt einrichten",
@@ -1212,7 +1200,6 @@
},
"robots": {
"title": "robots.txt",
"txtPlaceholder": "Leer lassen, um allen Bots zu erlauben diese Anwendung in den Index aufzunehmen",
"disableIndexingAction": "Indexierung deaktivieren"
},
"hstsPreload": "Aktivieren Sie den HSTS-Preload für diese Website und alle Subdomains"
@@ -1397,7 +1384,6 @@
"uninstallTabTitle": "Deinstallieren",
"importBackupDialog": {
"title": "Backup importieren",
"description": "Alle Daten, die zwischen jetzt und der letzten bekannten Sicherung erzeugt wurden, gehen unwiderruflich verloren. Es wird empfohlen, vor einem Importversuch ein Backup der aktuellen Daten zu erstellen.",
"uploadAction": "Datensicherungskonfiguration hochladen",
"importAction": "Importieren",
"remotePath": "Backup-Pfad",
@@ -1596,9 +1582,6 @@
},
"email": "E-Mail",
"passwordToken": "Passwort/Token",
"dialog": {
"title": "Docker-Registry"
},
"emptyPlaceholder": "Keine Docker-Registries"
},
"dockerRegistres": {
File diff suppressed because it is too large Load Diff
+295 -148
View File
@@ -17,10 +17,11 @@
"memoryRequirement": "Requiere al menos {{ size }} de memoria",
"lastUpdated": "Última actualización {{ date }}",
"cloudflarePortWarning": "El proxy de Cloudflare debe estar deshabilitado para que el dominio de la aplicación acceda a este puerto",
"portReadOnly": "solo lectura"
"portReadOnly": "solo lectura",
"ephemeralPortWarning": "El uso de puertos efímeros puede provocar conflictos impredecibles."
},
"unstable": "Inestable",
"searchPlaceholder": "Busca alternativas como Github, Dropbox, Slack, Trello, …",
"searchPlaceholder": "Busca alternativas como GitHub, Dropbox, Slack, Trello, …",
"category": {
"newApps": "Nuevas Aplicaciones",
"popular": "Popular",
@@ -40,13 +41,18 @@
},
"action": {
"logs": "Registros",
"reboot": "Reiniciar"
"reboot": "Reiniciar",
"remove": "Borrar",
"edit": "Editar",
"add": "Añadir",
"next": "Siguiente"
},
"table": {
"date": "Fecha"
"date": "Fecha",
"version": "Versión"
},
"actions": "Acciones",
"displayName": "Nombre a mostrar",
"displayName": "Nombre para mostrar",
"username": "Nombre de Usuario",
"dialog": {
"yes": "Si",
@@ -66,7 +72,8 @@
"select": "Seleccionar"
},
"navbar": {
"users": "Usuarios"
"users": "Usuarios",
"groups": "Grupos"
},
"statusEnabled": "Habilitado",
"loadingPlaceholder": "Cargando"
@@ -87,13 +94,14 @@
"sso": "Inicia sesión con las credenciales de Cloudron",
"email": "Inicia sesión con el correo electrónico",
"openid": "Iniciar sesión con Cloudron OpenID"
}
},
"noMatchesPlaceholder": "No hay aplicaciones que coincidan"
},
"users": {
"addUserDialog": {
"title": "Añadir Usuario",
"addUserAction": "Añadir Usuario",
"sendInviteCheckbox": "Enviar email de invitación ahora"
"addUserAction": "Añadir",
"sendInviteCheckbox": "Enviar correo electrónico de invitación"
},
"externalLdap": {
"errorSelfSignedCert": "El servidor está utilizando un certificado no válido o autofirmado.",
@@ -109,7 +117,7 @@
"syncGroups": "Sincronizar Grupos",
"usernameField": "Campo de Nombre de Usuario",
"filter": "Filtro",
"acceptSelfSignedCert": "Aceptar certificado autofirmado",
"acceptSelfSignedCert": "Aceptar Certificado Autofirmado",
"server": "URL del Servidor",
"provider": "Proveedor",
"noopInfo": "La autentificación LDAP no está configurada.",
@@ -121,13 +129,15 @@
"settings": {
"saveAction": "Guardar",
"require2FACheckbox": "Requerir que los usuarios configuren 2FA",
"allowProfileEditCheckbox": "Permitir a los usuarios editar su nombre y correo"
"allowProfileEditCheckbox": "Permitir a los usuarios editar su nombre y correo",
"title": "Ajustes"
},
"groups": {
"externalLdapTooltip": "Desde un directorio LDAP externo",
"users": "Usuarios",
"name": "Nombre",
"emptyPlaceholder": "No hay grupos aún"
"emptyPlaceholder": "No hay grupos",
"noMatchesPlaceholder": "No coincide ningún grupo"
},
"users": {
"resetPasswordTooltip": "Restablece la contraseña",
@@ -140,8 +150,10 @@
"groups": "Grupos",
"user": "Usuario",
"setGhostTooltip": "Suplantar",
"invitationTooltip": "Invitar Usuario",
"mailmanagerTooltip": "Este usuario puede administrar usuarios y buzones de correo"
"invitationTooltip": "Invitar",
"mailmanagerTooltip": "Este usuario puede administrar usuarios y buzones de correo",
"noMatchesPlaceholder": "No coincide ningún usuario",
"emptyPlaceholder": "Sin usuarios"
},
"role": {
"owner": "Super-administrador",
@@ -163,17 +175,18 @@
},
"deleteGroupDialog": {
"deleteAction": "Borrar",
"description": "El grupo todavía tiene {{ memberCount }} miembro(s). ¿Estás seguro de que este grupo no se está usando?",
"title": "Borra grupo {{ name }}"
"description": "Este grupo tiene {{ memberCount }} miembros. ¿Seguro que quieres eliminarlo?",
"title": "Borrar Grupo {{ name }}"
},
"editGroupDialog": {
"externalLdapWarning": "Este grupo se sincroniza desde el directorio LDAP externo.",
"title": "Editar Grupo {{ nombre }}"
"title": "Editar Grupo {{ name }}"
},
"group": {
"addGroupAction": "Añadir Grupo",
"addGroupAction": "Añadir",
"users": "Usuarios",
"name": "Nombre"
"name": "Nombre",
"allowedApps": "Aplicaciones permitidas"
},
"addGroupDialog": {
"title": "Añadir Grupo"
@@ -185,12 +198,12 @@
"deleteUserDialog": {
"deleteAction": "Eliminar",
"description": "Después de la eliminación, el usuario no podrá acceder al panel de control ni iniciar sesión en ninguna de las aplicaciones. Tenga en cuenta que los datos de usuario dentro de las aplicaciones no se eliminan.",
"title": "Borrar usuario {{ nombre de usuario }}"
"title": "Borrar Usuario {{ username }}"
},
"user": {
"activeCheckbox": "El Usuario está activo",
"recoveryEmail": "Email para recuperar contraseña",
"primaryEmail": "Email principal",
"activeCheckbox": "Usuario activo",
"recoveryEmail": "Correo electrónico de recuperación de contraseña",
"primaryEmail": "Email Principal",
"displayName": "Nombre para mostrar",
"usernamePlaceholder": "Opcional. Si no se proporciona, el usuario puede elegirlo durante el registro",
"noGroups": "No hay grupos disponibles.",
@@ -199,12 +212,12 @@
"username": "Usuario",
"fullName": "Nombre Completo",
"displayNamePlaceholder": "Opcional. Si no se proporciona, el usuario puede proporcionarlo durante el registro",
"fallbackEmailPlaceholder": "Opcional. Si no se especifica, se utilizará el correo electrónico principal"
"fallbackEmailPlaceholder": "Si no se especifica, se utilizará el correo electrónico principal"
},
"setGhostDialog": {
"title": "Crear contraseña para suplantar {{ username }}",
"description": "Establecer una contraseña temporal para iniciar sesión en nombre de este usuario en las aplicaciones o en el panel. Esta contraseña es válida por 6 horas.",
"password": "Contraseña",
"title": "Suplantar al usuario {{ username }}",
"description": "Establece una contraseña temporal para iniciar sesión en las aplicaciones o el panel de control. Esta contraseña es válida por 6 horas.",
"password": "Contraseña temporal",
"setPassword": "Establecer contraseña",
"generatePassword": "Generar Contraseña"
},
@@ -224,32 +237,36 @@
"placeholder": "Dirección IP o Subred separada por líneas",
"label": "Acceso Restringido"
},
"description": "Cloudron puede actuar como un servidor de directorio de usuarios central para aplicaciones externas.",
"description": "El servidor LDAP permite que las aplicaciones externas autentifiquen a los usuarios en el directorio de usuarios de Cloudron.",
"secret": {
"label": "Vincular Contraseña",
"description": "Todas las consultas LDAP deben autentificarse con este secreto y el DN de usuario <i>{{ userDN }}</i>",
"description": "Autentificar consultas con el DN de usuario <i>{{ userDN }}</i> y este secreto",
"url": "URL del Servidor"
},
"cloudflarePortWarning": "El proxy de Cloudflare debe estar deshabilitado en el dominio del panel para acceder al servidor LDAP"
}
"cloudflarePortWarning": "El proxy de Cloudflare debe estar deshabilitado en el dominio del panel para acceder al servidor LDAP",
"enable": "Habilitar el servidor LDAP",
"title": "Servidor LDAP",
"enabled": "Habilitar Servidor LDAP"
},
"title": "Usuarios"
},
"backups": {
"listing": {
"backupNow": "Hacer Copia de Seguridad Ahora",
"cleanupBackups": "Borrar Copias de Seguridad",
"tooltipDownloadBackupConfig": "Descarga Configuración de la Copia de Seguridad",
"appCount": "{{ appCount }} aplicaciones",
"cleanupBackups": "Limpiar Backups",
"tooltipDownloadBackupConfig": "Descargar configuración",
"appCount": "{{ appCount }} Aplicación(es)",
"noApps": "Sin Aplicaciones",
"version": "Versión",
"contents": "Contenidos",
"noBackups": "No se han hecho copias de seguridad aún.",
"title": "Listado",
"noBackups": "No hay copias de seguridad",
"title": "Copias de seguridad del sistema",
"tooltipPreservedBackup": "Esta copia de seguridad se conservará"
},
"schedule": {
"retentionPolicy": "Política de retención",
"schedule": "Programar",
"title": "Programación y retención"
"title": "Horario y retención"
},
"location": {
"remount": "Volver a montar almacenamiento"
@@ -258,7 +275,6 @@
"configureBackupStorage": {
"encryptionPasswordRepeat": "Repetir Contraseña",
"encryptionPasswordPlaceholder": "Frase de contraseña utilizada para cifrar las copias de seguridad",
"copyConcurrencyDigitalOceanNote": "Límites de velocidad de DigitalOcean Spaces en 20.",
"copyConcurrencyDescription": "Número de copias de archivos remotos en paralelo al realizar una copia de seguridad.",
"copyConcurrency": "Copiar simultaneidad",
"uploadConcurrencyDescription": "Número de archivos para cargar en paralelo al realizar una copia de seguridad",
@@ -270,7 +286,7 @@
"memoryLimitDescription": "Límite de memoria para la tarea de backup. Ajuste esto si aumenta los valores de simultaneidad de sus valores predeterminados.",
"memoryLimit": "Límite de Memoria",
"encryptionDescription": "Guarde esta frase de contraseña en un lugar seguro. Cloudron no almacena esta contraseña. Las copias de seguridad no se pueden descifrar sin la frase de contraseña",
"encryptionPassword": "Contraseña de cifrado (opcional)",
"encryptionPassword": "Contraseña de cifrado",
"s3LikeNote": "Elimine cualquier regla del ciclo de vida de vencimiento de los objetos, ya que dañará las copias de seguridad de rsync.",
"format": "Formato de Almacenamiento",
"gcsServiceKey": "Clave de cuenta de servicio",
@@ -279,14 +295,14 @@
"region": "Región",
"prefix": "Prefijo",
"bucketName": "Nombre del depósito",
"acceptSelfSignedCerts": "Aceptar certificado autofirmado",
"acceptSelfSignedCerts": "Aceptar Certificado autofirmado",
"s3Endpoint": "Punto final",
"hardlinksLabel": "Usar enlaces duros",
"localDirectory": "Directorio local para copias de seguridad",
"mountPointDescription": "El punto de montaje debe configurarse manualmente. Consulta esta <a href=\"{{ providerDocsLink }}\" target=\"_blank\"> documentación </a>.",
"mountPoint": "Punto de montaje",
"provider": "Proveedor de almacenamiento",
"title": "Configurar el almacenamiento de la Copia de Seguridad",
"title": "Configurar el sitio de respaldo",
"password": "Contraseña",
"diskPath": "Ruta del disco",
"server": "IP del servidor o Nombre de host",
@@ -298,22 +314,38 @@
"cifsSealSupport": "Utiliza la encriptación seal. Requiere al menos SMB v3",
"chown": "El sistema de archivos remoto admite chown",
"encryptFilenames": "Encriptar nombres de archivo",
"preserveAttributesLabel": "Conservar atributos de archivo"
"preserveAttributesLabel": "Conservar atributos de archivo",
"name": "Nombre",
"encryptionHint": "Sugerencia de contraseña de cifrado",
"usesEncryption": "La copia de seguridad utiliza cifrado",
"useForUpdates": "Guarda aquí las copias de seguridad de las actualizaciones automáticas",
"backupContents": {
"title": "Contenido de la copia de seguridad",
"description": "Selecciona qué respaldar en este sitio.",
"everything": "Todo",
"excludeSelected": "Excluir seleccionado",
"includeOnlySelected": "Incluir sólo lo seleccionado"
},
"automaticUpdates": {
"title": "Copias de seguridad de actualizaciones automáticas",
"description": "Siempre se crea una copia de seguridad antes de aplicar las actualizaciones automáticas. Elija si desea guardar esas copias de seguridad en este sitio."
},
"useEncryption": "Cifrar Copias de seguridad"
},
"configureBackupSchedule": {
"retentionPolicy": "Política de Retención",
"hours": "Horas",
"days": "Días",
"scheduleDescription": "Selecciona los días y horas durante los cuales Cloudron realizará la copia de seguridad. Ten cuidado de no superponer este programa con el <a href=\"/#/settings\"> programa de actualización </a>.",
"schedule": "Programar",
"title": "Configurar la Programación y Retención de la Copia de Seguridad"
},
"backupDetails": {
"list": "Hace referencia a copias de seguridad de {{appCount}} aplicaciones",
"list": "Hace referencia a copias de seguridad de {{appCount}} Aplicaciones",
"version": "Versión",
"date": "Fecha",
"id": "ID",
"title": "Detalles de la Copia de Seguridad"
"title": "Detalles de la Copia de Seguridad",
"size": "Tamaño",
"duration": "Duración"
},
"backupEdit": {
"title": "Editar Backup",
@@ -340,14 +372,28 @@
},
"deleteArchive": {
"deleteAction": "Borrar"
}
},
"sites": {
"title": "Sitios"
},
"site": {
"addDialog": {
"title": "Agregar sitio de respaldo"
}
},
"configAction": "Configuración",
"contentAction": "Contenido",
"configureContent": {
"title": "Configurar contenido de la copia de seguridad"
},
"useFileAndFileNameEncryption": "Se utiliza cifrado de archivos y nombres de archivos",
"useFileEncryption": "Se usa cifrado de archivos"
},
"profile": {
"enable2FAAction": "Habilita 2FA",
"disable2FAAction": "Deshabilita 2FA",
"changePasswordAction": "Cambiar Contraseña",
"changePasswordAction": "Cambiar contraseña",
"createApiToken": {
"generateToken": "Generar Token API",
"copyNow": "Por favor copia el token API ahora. No se volverá a mostrar por motivos de seguridad.",
"description": "Nuevo token API:",
"name": "Nombre del Token API",
@@ -356,7 +402,6 @@
"allowedIpRanges": "Rango(s) de IP permitido(s)"
},
"createAppPassword": {
"generatePassword": "Generar contraseña",
"copyNow": "Copia la contraseña ahora. No se volverá a mostrar por motivos de seguridad.",
"description": "Utiliza la siguiente contraseña para autentificarte en la aplicación:",
"app": "Aplicación",
@@ -367,18 +412,18 @@
"title": "Cambiar la dirección de correo electrónico de recuperación de contraseña"
},
"changeEmail": {
"title": "Cambiar el email principal",
"title": "Cambiar el Email principal",
"email": "Nuevo Correo Electrónico",
"password": "Contraseña para confirmación"
"password": "Confirmar con contraseña"
},
"loginTokens": {
"logoutAll": "Cerrar sesión de todo",
"logoutAll": "Cerrar todas las sesiones",
"description": "Tienes {{webadminTokenCount}} token (s) web activos y {{cliTokenCount}} token (s) CLI.",
"title": "Tokens de inicio de sesión"
},
"apiTokens": {
"noTokensPlaceholder": "No se han creado Tokens API",
"description": "Utilice estos tokens de acceso personal para autentificarse en la <a target=\"_blank\" href=\"{{ apiDocsLink }}\"> API de Cloudron </a>",
"noTokensPlaceholder": "Sin tokens API",
"description": "Utiliza estos tokens de acceso personal para autenticarse en la <a target=\"_blank\" href=\"{{ apiDocsLink }}\">API de Cloudron</a>.",
"name": "Nombre",
"title": "Tokens API",
"neverUsed": "nunca",
@@ -391,7 +436,7 @@
},
"appPasswords": {
"description": "Las contraseñas de aplicaciones son una medida de seguridad para proteger su cuenta de usuario de Cloudron. Si necesita acceder a una aplicación de Cloudron desde una aplicación móvil o cliente que no sea de confianza, puede iniciar sesión con su nombre de usuario y la contraseña alternativa generada aquí.",
"noPasswordsPlaceholder": "No se crearon contraseñas para la Aplicación",
"noPasswordsPlaceholder": "Sin contraseñas de aplicaciones",
"name": "Nombre",
"app": "Aplicación",
"title": "Contraseñas de la Aplicación"
@@ -411,13 +456,13 @@
"changePassword": {
"errorPasswordsDontMatch": "Las contraseñas no coinciden",
"newPasswordRepeat": "Repite nueva contraseña",
"newPassword": "Nueva Contraseña",
"newPassword": "Nueva contraseña",
"currentPassword": "Contraseña actual",
"title": "Cambia tu contraseña"
"title": "Cambiar contraseña"
},
"language": "Idioma",
"passwordRecoveryEmail": "Correo electrónico de recuperación de contraseña",
"primaryEmail": "Email principal",
"primaryEmail": "Email Principal",
"title": "Perfil",
"passwordResetNotification": {
"body": "Correo enviado a {{ email }}"
@@ -453,7 +498,7 @@
},
"details": "Detalles",
"time": "Hora",
"title": "Registro de Eventos del Correo electrónico",
"title": "Registro de Eventos",
"mailFrom": "De",
"rcptTo": "Para"
},
@@ -470,10 +515,11 @@
},
"domains": {
"testEmailTooltip": "Enviar Email de prueba",
"stats": "{{ mailboxCount }} Buzón (es) / Uso: {{ usage }}",
"stats": "Buzones: {{ mailboxCount }} / Uso: {{ usage }}",
"disabled": "Deshabilitado",
"outbound": "Solo Correo Saliente",
"title": "Dominios"
"title": "Dominios",
"inbound": "Entrada y salida"
},
"title": "Correo Electrónico",
"typeFilterHeader": "Todos los Eventos",
@@ -490,7 +536,7 @@
"blacklisteAddressesPlaceholder": "Patrones de direcciones de correo electrónico separados por líneas",
"customRules": "Reglas personalizadas de Spamassassin",
"blacklisteAddressesInfo": "Las direcciones coincidentes terminarán en la carpeta de correo no deseado del usuario. Los caracteres '*' y '?' están permitidos.",
"blacklisteAddresses": "Direcciones en Lista de bloqueo",
"blacklisteAddresses": "Lista de bloqueo de direcciones de correo electrónico",
"title": "Filtro de spam"
},
"changeMailSizeDialog": {
@@ -519,8 +565,8 @@
"title": "Pie de página"
},
"cloudronName": "Nombre de Cloudron",
"title": "Apariencia",
"backgroundImage": "Imagen de fondo de la página de inicio de sesión"
"title": "Marca",
"backgroundImage": "Fondo de la página de inicio de sesión"
},
"network": {
"firewall": {
@@ -565,7 +611,7 @@
},
"trustedIps": {
"summary": "{{ trustCount }} IPs confiables",
"description": "Se confiará en los encabezados HTTP de direcciones IP coincidentes",
"description": "Se confiará en los encabezados HTTP de direcciones IP coincidentes.",
"title": "Configurar IP confiables"
},
"trustedIpRanges": "Rangos e IPs confiables "
@@ -574,8 +620,7 @@
"configure": {
"title": "Configurar {{ name }}",
"resetToDefaults": "Restablecer a lo predeterminado",
"enableRecoveryMode": "Habilitar el Modo de Recuperación",
"recoveryModeDescription": "Si el servicio se reinicia constantemente o no responde debido a daños en los datos, pon el servicio en modo de recuperación. Utiliza las siguientes <a href=\"{{ docsLink }}\" target=\"_blank\">instrucciones</a> para volver a ejecutar el servicio."
"enableRecoveryMode": "Habilitar el Modo de Recuperación"
},
"restartActionTooltip": "Reiniciar",
"memoryLimit": "Límite de Memoria",
@@ -589,16 +634,22 @@
"title": "Cuenta Cloudron.io",
"subscriptionEndsAt": "Cancelado y finaliza el",
"subscriptionReactivateAction": "Reactivar Suscripción",
"setupAction": "Configurar Cuenta",
"setupAction": "Configurar cuenta",
"subscription": "Suscripción",
"cloudronId": "ID de Cloudron",
"subscriptionChangeAction": "Gestionar Suscripción",
"description": "Se utiliza una cuenta de Cloudron.io para acceder a la App Store y administrar su suscripción.",
"emailNotVerified": "Correo aún no verificado"
"description": "Se utiliza una cuenta de Cloudron.io para administrar tu suscripción.",
"emailNotVerified": "Correo aún no verificado",
"account": "Cuenta",
"unlinkAction": "Desvincular cuenta",
"unlinkDialog": {
"title": "Desvincular Cuenta Cloudron.io",
"description": "Esto desvinculará este Cloudron de la cuenta actual de Cloudron.io. Posteriormente, podrá <a href=\"https://docs.cloudron.io/appstore/#account-change\" target=\"_blank\">vincularse</a> con otra cuenta."
}
},
"title": "Sistema",
"title": "Ajustes",
"updateScheduleDialog": {
"description": "Seleccione los días y horas durante los cuales Cloudron aplicará actualizaciones automáticas de la plataforma y la aplicación. Tenga cuidado de no superponer esta programación con la <a href=\"/#/backups\"> programación de copias de seguridad </a>.",
"description": "Establece los días y horarios para las actualizaciones automáticas de la plataforma y la aplicación. Asegúrate de que esta programación no coincida con la programación de las copias de seguridad.",
"hours": "Horas",
"days": "Días",
"selectOne": "Seleccione al menos un día y una hora",
@@ -611,16 +662,17 @@
"updateAvailableAction": "Actualización Disponible",
"checkForUpdatesAction": "Buscar Actualizaciones",
"title": "Actualizaciones",
"description": "Las actualizaciones de plataformas y aplicaciones se aplican automáticamente según la programación en la <a href=\"/#/settings\">Zona horaria del sistema</a>.",
"description": "Las actualizaciones de la plataforma y de la aplicación se aplican según el cronograma establecido aquí, utilizando la <a href=\"/#/system-settings\">Zona horaria del sistema</a>.",
"disabled": "Deshabilitado",
"schedule": "Programar"
"schedule": "Programar",
"onLatest": "el último"
},
"language": {
"description": "El idioma predeterminado de este Cloudron se puede configurar aquí. Esto se utilizará también para correos electrónicos transaccionales como invitaciones de usuario y restablecimiento de contraseña. Cada usuario también puede cambiar el idioma preferido para el panel individualmente en el perfil.",
"description": "Establece el idioma predeterminado para los correos electrónicos de Cloudron y del sistema (p. ej., invitaciones y restablecimiento de contraseña). Los usuarios pueden configurar el idioma del panel en su perfil.",
"title": "Idioma"
},
"timezone": {
"description": "La configuración de zona horaria actual es <b>{{ timeZone }}</b>. Esta configuración se utiliza para programar tareas de copia de seguridad y actualizaciones. Las marcas de tiempo en la interfaz de usuario siempre se muestran utilizando la zona horaria del navegador.",
"description": "Se utiliza para programar copias de seguridad y actualizaciones. Las marcas de tiempo de la interfaz de usuario siempre siguen la zona horaria del navegador.",
"title": "Zona horaria del Sistema"
},
"registryConfig": {
@@ -638,11 +690,11 @@
}
},
"domains": {
"title": "Dominios y Certificados",
"title": "Dominios",
"changeDashboardDomain": {
"description": "Esto moverá el panel al subdominio <code>my</code> del dominio seleccionado.",
"changeAction": "Cambiar Dominio",
"title": "Cambiar Dominio del Panel"
"changeAction": "Cambiar dominio",
"title": "Dominio del Panel"
},
"domainDialog": {
"cloudflareTokenType": "Tipo de Token",
@@ -664,8 +716,8 @@
"nameComUsername": "Usuario de Name.com",
"nameComApiToken": "Token API",
"namecheapApiKey": "Clave API",
"manualInfo": "Todos los registros DNS deben configurarse manualmente antes de la instalación de cada aplicación.",
"letsEncryptInfo": "Let's Encrypt requiere que tu servidor sea accesible en el puerto 80",
"manualInfo": "Todos los registros DNS deben configurarse manualmente antes de instalar una aplicación",
"letsEncryptInfo": "Let's Encrypt requiere que tu servidor sea accesible en el puerto 80.",
"advancedAction": "Configuración Avanzada…",
"zoneName": "Nombre de Zona (Opcional)",
"fallbackCert": "Certificado alternativo (opcional)",
@@ -676,18 +728,17 @@
"netcupCustomerNumber": "Número de cliente",
"netcupApiKey": "Clave API",
"netcupApiPassword": "Contraseña API",
"addDescription": "Agregar un dominio le permite instalar aplicaciones en subdominios de este dominio. La configuración de correo electrónico para el dominio se puede configurar en la vista de correo electrónico.",
"namecheapUsername": "Usuario de Namecheap",
"namecheapInfo": "La IP del servidor debe estar incluida en la lista de permisos para esta clave de API.",
"namecheapInfo": "La dirección IP del servidor debe agregarse a la lista de permitidos para esta clave API",
"wildcardInfo": "Configurar manualmente los registros DNS A (IPv4) y AAAA (IPv6) para <b>*.{{ domain }}.</b> y <b>{{ domain }}.</b> que apuntan a este servidor",
"matrixHostname": "Ubicación del Servidor Matrix",
"fallbackCertCustomCertInfo": "Este <a href=\"{{ customCertLink }}\" target=\"_blank\"> certificado wildcard </a> se utilizará para todas las aplicaciones de este dominio. Si no se proporciona, se generará automáticamente un certificado autofirmado.",
"vultrToken": "Token Vultr",
"jitsiHostname": "Ubicación de Jitsi",
"wellKnownDescription": "Cloudron utilizará los valores para responder a las URLs <code>/.well-known/</code> . Ten en cuenta que la aplicación debe estar disponible en el dominio desnudo <code>{{ domain }}</code> para que esto funcione. Consulta <a href=\"{{docsLink}}\" target=\"_blank\">esta documentación</a> para más información.",
"wellKnownDescription": "Los valores se usarán para responder a las URL <code>https://{{ domain }}/.well-known/</code>. Ten en cuenta que una aplicación debe estar disponible en el dominio <code>{{ domain }}</code> para que esto funcione. Consulta la <a href=\"{{docsLink}}\" target=\"_blank\">documentación</a> para obtener más información.",
"hetznerToken": "Token de Hetzner",
"bunnyAccessKey": "Clave de acceso Bunny",
"cloudflareDefaultProxyStatus": "Habilitar proxy para nuevos registros DNS",
"cloudflareDefaultProxyStatus": "Habilitar el Proxy para nuevos Registros DNS",
"porkbunApikey": "Clave API",
"porkbunSecretapikey": "Clave API secreta",
"dnsimpleAccessToken": "Token de acceso",
@@ -701,12 +752,12 @@
"gandiTokenTypePAT": "Token de acceso personal (PAT)",
"inwxUsername": "Nombre de usuario",
"inwxPassword": "Contraseña",
"customNameservers": "El dominio utiliza servidores de nombres personalizados (vanity)"
"customNameservers": "El dominio utiliza servidores de nombres personalizados (Vanity)"
},
"renewCerts": {
"renewAllAction": "Renovar todos los Certificados",
"renewAllAction": "Renovar todos los certificados",
"description": "Los certificados de Let's Encrypt se renuevan automáticamente. Utiliza esta opción para activar una renovación inmediatamente.",
"title": "Renovar certificados"
"title": "Renovar Certificados"
},
"provider": "Proveedor",
"domain": "Dominio",
@@ -722,7 +773,9 @@
"domainWellKnown": {
"title": "Ubicaciones Well-known de {{ domain }}"
},
"tooltipWellKnown": "Establece las ubicaciones Well-Known"
"tooltipWellKnown": "Ubicaciones conocidas",
"emptyPlaceholder": "Sin Dominios",
"noMatchesPlaceholder": "No coincide ningún dominio"
},
"app": {
"appInfo": {
@@ -740,6 +793,13 @@
"appId": "ID de la Aplicación",
"description": "Título y Versión de la Aplicación",
"installedAt": "Instalado en"
},
"auto": {
"description": "Las actualizaciones de la aplicación se aplican periódicamente según el Programa de actualizaciones.",
"title": "Actualizaciones Automáticas"
},
"updates": {
"description": "Cloudron revisa periódicamente la App Store en busca de actualizaciones."
}
},
"updatesTabTitle": "Actualizaciones",
@@ -749,7 +809,7 @@
"noMounts": "No se ha montado ningún volumen.",
"volume": "Volumen",
"saveAction": "Guardar",
"title": "Montajes de volumen",
"title": "Montajes de Volumen",
"permissions": {
"label": "Permisos",
"readOnly": "Sólo Lectura",
@@ -774,21 +834,22 @@
"live": "En vivo",
"1h": "1 hora"
},
"diskIOTotal": "total: lectura {{ read }} / escritura {{ write }}",
"networkIOTotal": "total: entrante {{ inbound }} / saliente {{ outbound }}"
"diskIOTotal": "Lectura total: {{ read }} Escritura total: {{ write }}",
"networkIOTotal": "Total entrante: {{ inbound }} Total saliente: {{ outbound }}"
},
"displayTabTitle": "Presentación",
"backups": {
"backups": {
"importAction": "Importar Copia de Seguridad",
"createBackupAction": "Crear Copia de Seguridad",
"restoreTooltip": "Restaurar a esta Copia de Seguridad",
"cloneTooltip": "Clonar desde esta Copia de Seguridad",
"downloadConfigTooltip": "Descarga Configuración de la Copia de Seguridad",
"restoreTooltip": "Restaurar",
"cloneTooltip": "Clonar",
"downloadConfigTooltip": "Descargar configuración",
"time": "Creado en",
"title": "Backups",
"description": "Las copias de seguridad son instantáneas completas de la aplicación. Puede utilizar copias de seguridad de la aplicación para restaurar o clonar esta aplicación.",
"downloadBackupTooltip": "Descargar Copia de Seguridad"
"downloadBackupTooltip": "Descargar",
"checkIntegrity": "Comprobar la integridad"
},
"import": {
"title": "Importar desde una Copia de Seguridad Externa",
@@ -796,21 +857,20 @@
},
"auto": {
"title": "Backups automáticos",
"description": "Las copias de seguridad se crean periódicamente según la <a href=\"{{ backupLink }}\">Programación de copias de seguridad</a>."
"description": "La aplicación se respalda periódicamente según el Programa de copias de seguridad."
}
},
"security": {
"robots": {
"disableIndexingAction": "Desactivar indexado",
"title": "Robots.txt",
"txtPlaceholder": "Dejar en blanco para permitir que todos los bots indexen esta aplicación"
"title": "Robots.txt"
},
"csp": {
"saveAction": "Guardar",
"description": "La configuración de esta opción anulará cualquier encabezado CSP enviado por la propia aplicación",
"title": "Política de seguridad de contenido"
},
"hstsPreload": "Habilitar la carga previa de HSTS para este sitio y todos los subdominios"
"hstsPreload": "Habilitar la precarga de HSTS (incluidos los subdominios)"
},
"email": {
"from": {
@@ -841,6 +901,9 @@
"resizeAction": "Redimensionar",
"description": "Memoria máxima que la Aplicación puede usar",
"title": "Límite de Memoria"
},
"devices": {
"label": "Dispositivos"
}
},
"accessControl": {
@@ -899,11 +962,11 @@
"uninstallAction": "Desinstalar"
},
"importBackupDialog": {
"description": "Todos los datos generados entre ahora y la última copia de seguridad conocida se perderán de forma irrevocable. Se recomienda crear una copia de seguridad de los datos actuales antes de intentar una importación.",
"title": "Importar Backup",
"uploadAction": "Subir Configuración de Backup",
"uploadAction": "cargar una configuración de respaldo",
"importAction": "Importar",
"remotePath": "Ruta del Backup"
"remotePath": "Ruta del Backup",
"provideBackupInfo": "Proporciona la información de respaldo para restaurar desde allí, o"
},
"restoreDialog": {
"warning": "Todos los datos generados entre ahora y la última copia de seguridad conocida se perderán de forma irrevocable. Se recomienda crear una copia de seguridad de los datos actuales antes de intentar una restauración.",
@@ -947,14 +1010,20 @@
"projectWebsiteAction": "Sitio Web del proyecto",
"repair": {
"recovery": {
"title": "Recuperación en caso de accidente",
"title": "Modo de recuperación",
"restartAction": "Reiniciar",
"description": "Si la aplicación no responde, intenta reiniciarla. Si la aplicación se reinicia constantemente debido a un complemento roto o una configuración incorrecta, coloca la aplicación en modo de recuperación para acceder a la consola.\nUtiliza las siguientes <a href=\"{{ docsLink }}\" target=\"_blank\"> instrucciones </a> para volver a ejecutar la aplicación."
"description": "Para reparar complementos rotos o configuraciones incorrectas, coloque la aplicación en modo de recuperación.",
"disableAction": "Deshabilitar el modo de recuperación",
"enableAction": "Habilitar el modo de recuperación"
},
"taskError": {
"title": "Error de tarea",
"description": "Si una acción de configuración, actualización, restauración o copia de seguridad resultó en un error, se puede volver a intentar la tarea.",
"description": "Si una acción de instalación, configuración, actualización, restauración o copia de seguridad generó un error, puede volver a intentar la tarea.",
"retryAction": "Reintentar {{ task }} tarea"
},
"restart": {
"title": "Reiniciar",
"description": "Si la aplicación no responde, intenta reiniciarla."
}
},
"eventlogTabTitle": "Registro",
@@ -979,10 +1048,10 @@
},
"forumUrlAction": "¿Necesitas ayuda? Pregunta en el foro",
"addApplinkDialog": {
"title": "Añadir enlace externo de la aplicación"
"title": "Añadir enlace externo"
},
"editApplinkDialog": {
"title": "Editar enlace de la aplicación"
"title": "Editar enlace externo"
},
"applinks": {
"upstreamUri": "URL Externa",
@@ -1007,7 +1076,7 @@
"action": "Archivo",
"description": "La última copia de seguridad de la aplicación se agregará al <a href=\"/#/backups\">Archivo de aplicaciones</a>. La aplicación se desinstalará, pero se podrá restaurar desde la Vista de copias de seguridad. Las demás copias de seguridad se limpiarán según la política de copias de seguridad.",
"noBackup": "Esta aplicación no tiene copia de seguridad. Para archivarla, es necesario tener una copia de seguridad reciente.",
"latestBackupInfo": "La última copia de seguridad se creó el {{date}}.",
"latestBackupInfo": "La última copia de seguridad se creó en {{siteName}} a las {{date}}.",
"title": "Archivo"
},
"archiveDialog": {
@@ -1015,11 +1084,15 @@
"description": "Esto desinstalará la aplicación y colocará la última copia de seguridad de la aplicación creada el {{date}} en el Archivo de aplicaciones."
},
"configureTooltip": "Configurar",
"updateAvailableTooltip": "Actualización disponible"
"updateAvailableTooltip": "Actualización disponible",
"forumAction": "Foro",
"appLink": {
"title": "Enlace externo"
}
},
"system": {
"cpuUsage": {
"title": "Uso de CPU"
"title": "CPU"
},
"systemMemory": {
"title": "Memoria del Sistema"
@@ -1032,19 +1105,25 @@
"uptime": "Tiempo de actividad",
"activationTime": "Tiempo de creación de Cloudron",
"product": "Producto",
"vendor": "Vendedor"
"vendor": "Vendedor",
"cloudronVersion": "Versión de Cloudron",
"ubuntuVersion": "Versión de Ubuntu"
},
"graphs": {
"title": "Gráficos"
},
"locale": {
"title": "Configuración regional"
"title": "Lugar"
},
"title": "Sistema",
"settings": {
"title": "Ajustes"
}
},
"support": {
"help": {
"title": "Ayuda",
"description": "Utiliza los siguientes recursos para obtener ayuda y soporte:\n* [Foro de Cloudron]({{ forumLink }}) - Utiliza las categorías específicas de Soporte y Aplicación si tiene preguntas.\n* [Base de conocimientos y documentos de Cloudron]({{ docsLink }})\n* [API y empaquetado de aplicaciones personalizadas]({{ packagingLink }})\n"
"description": "Utiliza los siguientes recursos para obtener ayuda y soporte:\n\n* [Foro de Cloudron]({{ forumLink }}) - Utiliza las categorías de Soporte y Aplicaciones para preguntas.\n* [Documentación]({{ docsLink }})\n* [Empaquetado de aplicaciones]({{ packagingLink }})\n* [API]({{ apiLink }})"
}
},
"volumes": {
@@ -1062,16 +1141,17 @@
"user": "Usuario",
"privateKey": "Clave privada SSH"
},
"openFileManagerActionTooltip": "Abrir Gestor de Archivos",
"openFileManagerActionTooltip": "Gestor de Archivos",
"name": "Nombre",
"title": "Volúmenes",
"description": "Los volúmenes son sistemas de archivos locales o remotos. Se pueden usar como el almacenamiento de datos principal de una aplicación o como una ubicación de almacenamiento compartida entre aplicaciones.",
"localDirectory": "Directorio Local",
"mountType": "Tipo de montaje",
"remountActionTooltip": "Volver a montar Volumen",
"remountActionTooltip": "Volver a montar",
"editVolumeDialog": {
"title": "Editar volumen {{ name }}"
}
},
"emptyPlaceholder": "No hay volúmenes"
},
"eventlog": {
"filterAllEvents": "Todos los Eventos",
@@ -1181,15 +1261,15 @@
}
},
"logs": {
"download": "Descarga los Registros Completos",
"clear": "Borrar Vista",
"download": "Descarga los registros completos",
"clear": "Borrar vista",
"title": "Registros"
},
"email": {
"signature": {
"plainTextFormat": "Formato del texto",
"plainTextFormat": "Formato de texto",
"htmlFormat": "Formato HTML",
"title": "Firma",
"title": "Firma de correo electrónico",
"description": "El texto aquí se adjuntará a todos los correos electrónicos que se envíen desde este dominio."
},
"incoming": {
@@ -1204,14 +1284,19 @@
"addAction": "Añadir",
"name": "Nombre",
"owner": "Propietario",
"usage": "Uso"
"usage": "Uso",
"stats": "Conteo: {{ mailboxCount }} / Uso: {{ usage }}",
"emptyPlaceholder": "No hay buzones",
"noMatchesPlaceholder": "No hay buzones coincidentes"
},
"mailinglists": {
"title": "Listas de correo",
"name": "Nombre",
"members": "Lista de miembros",
"everyoneTooltip": "Publicación permitida por los no miembros",
"membersOnlyTooltip": "Publicación restringida solo para miembros"
"membersOnlyTooltip": "Publicación restringida solo para miembros",
"emptyPlaceholder": "No hay listas de correo",
"noMatchesPlaceholder": "No hay listas de correo que coincidan"
},
"outgointServerInfo": "Correo Saliente (SMTP)",
"sieveServerInfo": "ManageSieve",
@@ -1222,7 +1307,8 @@
"howToConnectDescription": "Utiliza la siguiente configuración para configurar los clientes de correo electrónico.",
"incomingUserInfo": "Nombre de Usuario",
"incomingPasswordInfo": "Contraseña",
"incomingPasswordUsage": "Contraseña del propietario del buzón"
"incomingPasswordUsage": "Contraseña del propietario del buzón",
"description": "Recibir correos electrónicos entrantes para este dominio."
},
"outbound": {
"noopAdminDomainWarning": "Cloudron no puede enviar invitaciones de usuario, restablecimiento de contraseña y otras notificaciones cuando el correo electrónico está deshabilitado en el dominio principal",
@@ -1231,7 +1317,7 @@
"spfDocInfo": "Cloudron no configura automáticamente el registro SPF. Configúralo manualmente siguiendo la <a href=\"{{ spfDocsLink }}\" target=\"_blank\"> {{name}} documentación </a>.",
"host": "Host SMTP",
"port": "Puerto SMTP (STARTTLS)",
"selfsignedCheckbox": "Aceptar certificado autofirmado",
"selfsignedCheckbox": "Aceptar Certificado Autofirmado",
"apiTokenOrKey": "Token/Key API",
"username": "Nombre de usuario",
"password": "Contraseña"
@@ -1240,12 +1326,18 @@
"description": "Este servidor de correo (host inteligente) se utilizará para enviar los correos salientes de las aplicaciones instaladas en este dominio."
},
"config": {
"title": "Configuración de Correo electrónico {{ domain }}",
"clientConfiguration": "Configuración de clientes de correo electrónico"
"title": "Configuración de correo electrónico {{ domain }}",
"clientConfiguration": "Configuración de clientes de correo electrónico",
"sending": {
"title": "Enviando"
},
"receiving": {
"title": "Recibiendo"
}
},
"updateMailboxDialog": {
"activeCheckbox": "El buzón de correo está activo",
"enablePop3": "Habilitar acceso POP3"
"activeCheckbox": "Buzón activo",
"enablePop3": "Acceso POP3"
},
"dnsStatus": {
"ptrInfo": "El registro PTR lo establece tu proveedor de VPS y no tu proveedor de DNS.",
@@ -1270,8 +1362,7 @@
"setupDnsInfo": "Utiliza esta opción para configurar automáticamente los registros DNS relacionados con el correo electrónico. Dejar esta opción sin marcar es útil para crear buzones de correo e <a href=\"{{ importEmailDocsLink }}\"> importar correo electrónico </a> antes de publicarlo.",
"title": "¿Habilitar el correo electrónico para {{ domain }}?",
"setupDnsCheckbox": "Configura los registros DNS de correo ahora",
"enableAction": "Habilitar",
"cloudflareInfo": "El dominio del servidor de correo <code>{{ adminDomain }}</code> es administrado por Cloudflare. Verifica que el proxy de Cloudflare esté deshabilitado para <code>{{ mailFqdn }}</code> y configurado en <code>DNS only</code>. Esto es necesario porque Cloudflare no realiza proxy de correo electrónico."
"enableAction": "Habilitar"
},
"disableEmailDialog": {
"description": "Esto configurará Cloudron para que deje de recibir correos electrónicos para <b> {{dominio}} </b>. Los buzones de correo y las listas asociadas con este dominio no se eliminarán.",
@@ -1279,7 +1370,7 @@
"disableAction": "Deshabilitar"
},
"addMailinglistDialog": {
"membersOnlyCheckbox": "Restringir la publicación solo a miembros",
"membersOnlyCheckbox": "Restringir la publicación a los miembros de la lista",
"title": "Añadir Lista de correo",
"members": "Lista de miembros",
"name": "Nombre"
@@ -1292,31 +1383,28 @@
"deleteMailboxDialog": {
"description": "Después de la eliminación, los correos electrónicos enviados a este buzón rebotarán. Puedes optar por no eliminar los correos electrónicos de este buzón con fines de archivo. Los correos electrónicos archivados se encuentran en <code>/home/yellowtent/boxdata/mail/vmail</code> en el servidor.",
"title": "Borrar Buzón de correo {{ name }}@{{ domain }}",
"purgeMailboxCheckbox": "Borrar todos los correos y filtros dentro de este buzón de correo",
"purgeMailboxCheckbox": "Eliminar todos los correos y filtros de este buzón",
"deleteAction": "Borrar"
},
"masquerading": {
"title": "Enmascarado",
"description": "El enmascaramiento permite a los usuarios y aplicaciones enviar correos electrónicos con un nombre de usuario arbitrario en la dirección DE."
},
"addMailboxDialog": {
"title": "Añadir Buzón de correo",
"name": "Nombre"
"name": "Nombre",
"incomingDisabledWarning": "El correo electrónico entrante para este dominio no está habilitado."
},
"editMailboxDialog": {
"title": "Editar Buzón de correo {{ name }}@{{ domain }}",
"title": "Editar buzón {{ nombre }}@{{ dominio }}",
"owner": "Propietario del Buzón de correo",
"aliases": "Alias",
"noAliases": "No hay alias configuradas.",
"addAliasAction": "Añadir un alias",
"addAnotherAliasAction": "Añadir otro alias",
"enableStorageQuota": "Habilitar cuota de almacenamiento"
"enableStorageQuota": "Cuota de almacenamiento"
},
"editMailinglistDialog": {
"title": "Editar Lista de correo {{ name }}@{{ domain }}"
"title": "Editar lista de correo {{ nombre }}@{{ dominio }}"
},
"updateMailinglistDialog": {
"activeCheckbox": "La lista de correo está activa"
"activeCheckbox": "Lista de correo activa"
},
"howToConnectInfoModal": "Configuración de clientes de correo electrónico"
},
@@ -1330,7 +1418,7 @@
},
"notifications": {
"dismissTooltip": "Descartar",
"markAllAsRead": "Marcar Todos como leídos",
"markAllAsRead": "Marcar todos como leídos",
"settings": {
"rebootRequired": "Es necesario reiniciar el servidor",
"cloudronUpdateFailed": "La actualización de Cloudron ha fallado",
@@ -1416,7 +1504,8 @@
"2faToken": "Token 2FA",
"resetPasswordAction": "Resetear contraseña",
"errorIncorrect2FAToken": "El token 2FA es inválido",
"errorInternal": "Error interno, prueba de nuevo más tarde"
"errorInternal": "Error interno, prueba de nuevo más tarde",
"loginAction": "Acceder"
},
"newLoginEmail": {
"subject": "[<% = cloudron%>] Nuevo inicio de sesión en tu cuenta",
@@ -1427,7 +1516,7 @@
},
"storage": {
"mounts": {
"description": "Las aplicaciones pueden acceder a <a href=\"/#/volumes\">volúmenes</a> montados a través del directorio <code>/media/(volume name)</code>. Estos datos no están incluidos en la copia de seguridad de la aplicación."
"description": "Se puede acceder a los volúmenes montados en <code>/media/(nombre del volumen)</code>. Los datos montados no se incluyen en la copia de seguridad de la aplicación."
}
},
"oidc": {
@@ -1440,9 +1529,9 @@
"id": "ID de cliente",
"secret": "Secreto de cliente",
"signingAlgorithm": "Algoritmo de firma",
"loginRedirectUri": "URLs de devolución de llamada de inicio de sesión (separadas por comas)"
"loginRedirectUri": "URL de devolución de llamada de inicio de sesión (separadas por comas)"
},
"description": "Cloudron puede actuar como proveedor de OpenID Connect para aplicaciones internas y servicios externos.",
"description": "El proveedor OpenID puede ser utilizado por aplicaciones externas para el inicio de sesión único.",
"editClientDialog": {
"title": "Editar cliente {{ client }}"
},
@@ -1452,11 +1541,69 @@
},
"env": {
"discoveryUrl": "URL de descubrimiento"
},
"clients": {
"title": "Clientes OpenID",
"empty": "No hay clientes OpenID"
}
},
"userdirectory": {
"settings": {
"title": "Ajustes"
}
},
"backup": {
"sites": {
"lastRun": "Última ejecución",
"title": "Sitios de respaldo",
"emptyPlaceholder": "No hay Sitios de Respaldo"
},
"site": {
"removeDialog": {
"description": "Esto también eliminará cualquier entrada de respaldo vinculada a este sitio.",
"title": "¿Realmente quieres eliminar este sitio de respaldo?"
}
},
"target": {
"fileCount": "Archivos",
"label": "Sitio de respaldo",
"size": "Tamaño"
}
},
"dockerRegistries": {
"server": "Dirección del servidor",
"provider": "Proveedor",
"username": "Nombre de usuario",
"title": "Registros de Docker",
"description": "Cloudron puede extraer e instalar aplicaciones personalizadas desde un registro de Docker privado.",
"removeDialog": {
"title": "Borrar {{ serverAddress }}"
},
"email": "Correo electrónico",
"passwordToken": "Contraseña/Token",
"emptyPlaceholder": "No hay registros de Docker"
},
"dockerRegistres": {
"removeDialog": {
"description": "¿Realmente quieres eliminar este registro?"
}
},
"appearance": {
"title": "Apariencia"
},
"dashboard": {
"title": "Panel"
},
"externallinks": {
"label": "Enlaces externos",
"description": "Agrega accesos directos a servicios externos en el panel de control."
},
"server": {
"title": "Servidor"
},
"archives": {
"listing": {
"placeholder": "No hay aplicaciones archivadas"
}
}
}
-14
View File
@@ -228,7 +228,6 @@
},
"createAppPassword": {
"copyNow": "Veillez à copier le mot de passe maintenant. Il ne s'affichera plus pour des raisons de sécurité.",
"generatePassword": "Générer un mot de passe",
"app": "Application",
"name": "Nom du mot de passe",
"title": "Créer un mot de passe d'application",
@@ -247,7 +246,6 @@
"createApiToken": {
"name": "Nom du jeton API",
"description": "Nouveau jeton API :",
"generateToken": "Générer un jeton API",
"title": "Créer un jeton API",
"copyNow": "Veillez à copier le jeton API maintenant. Il ne s'affichera plus pour des raisons de sécurité.",
"access": "Accès API"
@@ -284,8 +282,6 @@
"days": "Jours",
"hours": "Heures",
"title": "Paramétrer la planification et la conservation des sauvegardes",
"schedule": "Fréquence",
"scheduleDescription": "Sélectionnez les jours et heures de lancement de la sauvegarde de Cloudron. Veillez à ne pas planifier la sauvegarde au même moment que les <a href=\"/#/settings\">mises à jour</a>.",
"retentionPolicy": "Politique de conservation"
},
"schedule": {
@@ -321,7 +317,6 @@
"encryptionDescription": "Conservez cette phrase secrète en lieu sûr. Cloudron ne stocke pas ce mot de passe. Les sauvegardes ne pourront pas être déchiffrés sans cette phrase secrète.",
"downloadConcurrency": "Simultanéité des téléchargements",
"uploadConcurrency": "Simultanéité des chargements",
"copyConcurrencyDigitalOceanNote": "La limite pour DigitalOcean Spaces est fixée à 20.",
"encryptionPasswordPlaceholder": "Phrase secrète utilisée pour le chiffrement des sauvegardes",
"uploadPartSize": "Taille des partitions",
"uploadPartSizeDescription": "Taille des partitions dans le cadre du chargement partitionné. Jusqu'à 3 partitions peuvent être chargées simultanément, chacune nécessitant sa part de mémoire.",
@@ -629,7 +624,6 @@
},
"robots": {
"disableIndexingAction": "Désactiver l'indexation",
"txtPlaceholder": "Laisser vide pour autoriser les robots à indexer cette application",
"title": "Robots.txt"
},
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
@@ -707,7 +701,6 @@
},
"importBackupDialog": {
"uploadAction": "Charger le fichier de configuration de la sauvegarde",
"description": "Toutes les données créées depuis la dernière sauvegarde connue seront définitivement perdues. Il est fortement recommandé de sauvegarder les données actuelles avant de lancer un import.",
"title": "Importer la sauvegarde",
"importAction": "Importer",
"remotePath": "Chemin de la sauvegarde"
@@ -955,7 +948,6 @@
"enableEmailDialog": {
"setupDnsInfo": "Utilisez cette option pour paramétrer automatiquement les enregistrements DNS pour la messagerie. Ne pas cocher cette option peut permettre de créer des adresses de messagerie et d<a href=\"{{ importEmailDocsLink }}\">importer des emails</a> avant le déploiement.",
"setupDnsCheckbox": "Paramétrer les enregistrements DNS pour la messagerie maintenant",
"cloudflareInfo": "Le domaine <code>{{ adminDomain }}</code> est géré par Cloudflare. Veuillez vérifier que la fonction proxy de Cloudflare est désactivée pour <code>{{ mailFqdn }}</code> et défini sur <code>DNS uniquement</code>. Cette mesure est nécessaire car Cloudflare ne propose pas de proxy de messagerie.",
"noProviderInfo": "Aucun fournisseur de DNS n'est paramétré. Les enregistrements DNS listés dans l'onglet État doivent être paramétrés manuellement.",
"description": "Cette action permettra à Cloudron de recevoir des emails pour <b>{{ domain }}</b>. Consultez la documentation pour ouvrir les <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">ports nécessaires</a> à la messagerie Cloudron.",
"title": "Activer la messagerie pour {{ domain }} ?",
@@ -976,10 +968,6 @@
"description": "Les enregistrements DNS peuvent présenter des erreurs pendant la propagation DNS (5 minutes environ). Consultez la documentation <a href=\"{{ emailDnsDocsLink }}\" target=\"_blank\">résolution des problèmes</a> pour obtenir de l'aide.",
"domain": "Domaine"
},
"masquerading": {
"description": "Le masquage permet aux utilisateurs et aux applications d'envoyer des emails avec un nom d'utilisateur arbitraire dans le champ de l'expéditeur.",
"title": "Masquage"
},
"outbound": {
"mailRelay": {
"spfDocInfo": "L'enregistrement SPF n'est pas automatiquement paramétré sur Cloudron. Pour le paramétrer manuellement, reportez-vous à la <a href=\"{{ spfDocsLink }}\" target=\"_blank\">documentation {{ name }}</a>.",
@@ -1023,7 +1011,6 @@
"description": "Cette action va permettre de réapprovisionner les enregistrements DNS de l'application et de la messagerie sur l'ensemble des domaines."
},
"domainDialog": {
"addDescription": "L'ajout d'un domaine vous permet d'installer des applications dans des sous-domaines de ce domaine. Les paramètres de messagerie relatifs à ce domaine peuvent être paramétrés dans l'onglet Messagerie.",
"netcupApiPassword": "Mot de passe API",
"netcupApiKey": "Clé API",
"netcupCustomerNumber": "Numéro de client",
@@ -1282,7 +1269,6 @@
"configure": {
"resetToDefaults": "Restaurer les paramètres par défaut",
"title": "Paramétrer {{ name }}",
"recoveryModeDescription": "Si le service ne cesse de redémarrer ou s'il ne répond pas en raison d'une altération des données, activez le mode récupération. Suivez ces <a href=\"{{ docsLink }}\" target=\"_blank\">instructions</a> pour remettre le service en marche.",
"enableRecoveryMode": "Activer le mode récupération"
},
"restartActionTooltip": "Redémarrer",
+1 -14
View File
@@ -137,8 +137,7 @@
"security": {
"robots": {
"title": "Robots.txt",
"disableIndexingAction": "Disabilita indicizzazione",
"txtPlaceholder": "Lascia vuoto per consentire a tutti i bot di indicizzare questa app"
"disableIndexingAction": "Disabilita indicizzazione"
},
"csp": {
"saveAction": "Salva",
@@ -160,7 +159,6 @@
"importBackupDialog": {
"importAction": "Importa",
"uploadAction": "Carica configurazione backup",
"description": "Tutti i dati generati tra ora e l'ultimo backup noto verranno persi irrevocabilmente. Si consiglia di creare un backup dei dati correnti prima di tentare un'importazione.",
"title": "Importa backup"
},
"uninstallDialog": {
@@ -375,10 +373,6 @@
"hostname": "Nome Host",
"description": "Lo stato dei record DNS potrebbe mostrare un errore durante la propagazione del DNS (~ 5 minuti). Consulta i documenti di <a href=\"{{ emailDnsDocsLink }}\" target=\"_blank\">risoluzione dei problemi</a> per assistenza."
},
"masquerading": {
"title": "Maschera",
"description": "Mascherare (masquerading) permette agli utenti e alle app di inviare e-mail con un nome arbitrario nell'indirizzo FROM."
},
"smtpStatus": {
"blacklisted": "L'IP di questo server {{ ip }} è su una blacklist.",
"notBlacklisted": "L'IP di questo server {{ ip }} <b>non</b> è su una blacklist."
@@ -388,7 +382,6 @@
"noProviderInfo": "Il fornitore di DNS non è impostato. Devi impostare manualmente i record DNS elencati nel tab di stato.",
"setupDnsCheckbox": "Imposta i record DNS",
"description": "Il Cloudron verrà configurato per ricevere e-mail su <b>{{ domain }}</b>. Leggi la documentazione su come aprire le <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">porte richieste</a>.",
"cloudflareInfo": "Il dominio <code>{{ adminDomain }}</code> è gestito da Cloudflare. Verifica che il Cloudflare proxying sia disabilitato per <code>{{ mailFqdn }}</code> ed è impostato su <code>DNS only</code>. Questa impostazione è necessaria perchè Cloudflare non fa il proxy per le e-mail.",
"enableAction": "Abilita",
"setupDnsInfo": "Usa questa opzione per l'impostazione automatica dei record DNS this option to automatically setup Email related DNS records. Leaving this option unchecked is useful for creating mail boxes and <a href=\"{{ importEmailDocsLink }}\">importing email</a> before going live."
},
@@ -542,7 +535,6 @@
"s3Endpoint": "Endpoint",
"encryptionPasswordRepeat": "Ripeti Password",
"encryptionPasswordPlaceholder": "Passphrase utilizzata per crittografare i backup",
"copyConcurrencyDigitalOceanNote": "Gli spazi DigitalOcean limitano la velocità a 20.",
"copyConcurrencyDescription": "Numero di copie di file remoti in parallelo durante il backup.",
"copyConcurrency": "Copia Contemporanea",
"uploadConcurrency": "Upload Contemporanei",
@@ -553,8 +545,6 @@
"retentionPolicy": "Politica di conservazione",
"hours": "Ore",
"days": "Giorni",
"scheduleDescription": "Seleziona i giorni e le ore durante i quali Cloudron eseguirà il backup. Fai attenzione a non sovrapporre questa pianificazione alla <a href=\"/#/settings\"> pianificazione degli aggiornamenti </a>.",
"schedule": "Pianifica",
"title": "Configura pianificazione e conservazione backup"
},
"backupDetails": {
@@ -587,14 +577,12 @@
"disable2FAAction": "Disabilita 2FA",
"changePasswordAction": "Cambia Password",
"createApiToken": {
"generateToken": "Genera Token API",
"copyNow": "Copia il token API ora. Non verrà mostrato di nuovo per motivi di sicurezza.",
"description": "Nuovo token API:",
"name": "Nome Token API",
"title": "Crea Token API"
},
"createAppPassword": {
"generatePassword": "Genera Password",
"copyNow": "Copia la password adesso. Non verrà mostrata di nuovo per motivi di sicurezza.",
"description": "Usa la seguente password per autenticarti con l'app:",
"name": "Nome password",
@@ -1002,7 +990,6 @@
"route53AccessKeyId": "Id della chiave di accesso",
"provider": "Provider DNS",
"domain": "Dominio",
"addDescription": "Aggiungere un dominio ti consentirà di installare delle app sui sottodomini di questo dominio. I parametri di configurazione per le e-mail di questo dominio possono essere configurati nel menù E-mail.",
"editTitle": "Configura {{ domain }}",
"addTitle": "Aggiungi dominio",
"matrixHostname": "Location del server matrix",
File diff suppressed because it is too large Load Diff
+50 -42
View File
@@ -2,7 +2,7 @@
"apps": {
"title": "As Minhas Aplicações",
"noApps": {
"description": "E que tal instalar algumas? Veja na <a href=\"{{ appStoreLink }}\">Loja de Aplicações</a>",
"description": "E que tal instalar algumas? Veja na <a href=\"{{ appStoreLink }}\">Loja de Aplicações</a>.",
"title": "Ainda sem aplicações instaladas!"
},
"noAccess": {
@@ -15,13 +15,14 @@
"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",
"displayName": "Nome a Exibir",
"rebootDialog": {
"description": "Utilize isto para aplicar as atualizações de segurança ou se tiver um comportamento inesperado. Todas as aplicações e serviços em execução neste Cloudron irão iniciar automaticamente quando o reinício estiver concluído.",
"title": "Deseja reiniciar o servidor?",
"description": "Todas as aplicações e serviços irão iniciar automaticamente. <br/><br/>Reiniciar agora o servidor?",
"title": "Reiniciar Servidor",
"rebootAction": "Reiniciar agora"
},
"offline": "Cloudron está off-line. A religar…",
@@ -39,7 +40,8 @@
"username": "Nome de Utilizador",
"actions": "Ações",
"table": {
"date": "Data"
"date": "Data",
"version": "Versão"
},
"action": {
"reboot": "Reiniciar",
@@ -47,7 +49,8 @@
"remove": "Remover",
"edit": "Editar",
"add": "Adicionar",
"next": "Seguinte"
"next": "Seguinte",
"configure": "Configurar"
},
"searchPlaceholder": "Pesquisar",
"multiselect": {
@@ -74,7 +77,7 @@
"memoryRequirement": "Requer pelo menos {{ size }} de memória",
"location": "Localização",
"manualWarning": "Configure manualmente os registos A (IPv4) e AAA (IPv6) para <b>{{ location }}</b> apontando para este servidor",
"userManagement": "Gestão de utilizadores",
"userManagement": "Gestão de Utilizadores",
"userManagementMailbox": "Todos os utilizadores com uma caixa de correio neste Cloudron têm acesso.",
"userManagementLeaveToApp": "Deixar a gestão de utilizadores para a aplicação",
"userManagementAllUsers": "Permitir todos os utilizadores deste Cloudron",
@@ -84,10 +87,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>.",
@@ -101,10 +105,10 @@
"title": "Alterar endereço de correio eletrónico principal"
},
"changePassword": {
"title": "Alterar palavra-passe",
"currentPassword": "Palavra-passe atual",
"newPassword": "Nova palavra-passe",
"newPasswordRepeat": "Repetir palavra-passe",
"title": "Alterar Palavra-passe",
"currentPassword": "Palavra-passe Atual",
"newPassword": "Nova Palavra-passe",
"newPasswordRepeat": "Repetir Nova Palavra-passe",
"errorPasswordsDontMatch": "As palavras-passe não coincidem"
},
"enable2FA": {
@@ -128,7 +132,6 @@
"allowedIpRangesPlaceholder": "IPs ou sub-redes separados por vírgulas"
},
"createAppPassword": {
"generatePassword": "Gerar Palavra-passe",
"name": "Nome da Palavra-passe",
"title": "Criar Palavra-passe da Aplicação",
"app": "Aplicação",
@@ -139,7 +142,6 @@
"name": "Nome do Código de API",
"title": "Criar Código de API",
"description": "Novo código de API:",
"generateToken": "Gerar Código de API",
"access": "Acesso de API",
"copyNow": "Por favor, copie o código da API agora. Este não será mostrado novamente por motivos de segurança.",
"allowedIpRanges": "Intervalo(s) de IP Permitido(s)"
@@ -148,7 +150,7 @@
"body": "Mensagem enviada para {{ email }}"
},
"title": "Perfil",
"primaryEmail": "E-mail principal",
"primaryEmail": "E-mail Principal",
"language": "Idioma",
"disable2FA": {
"title": "Desativar Autenticação de Dois Fatores",
@@ -167,7 +169,7 @@
"title": "Palavras-passe da Aplicação",
"app": "Aplicação",
"name": "Nome",
"noPasswordsPlaceholder": "Nenhumas Palavras-passe de Aplicação criadas",
"noPasswordsPlaceholder": "Sem palavras-passe da aplicação",
"description": "As palavras-passe da aplicação são uma medida de segurança para proteger a sua conta de utilizador Cloudron. Se precisar de aceder a uma aplicação Cloudron a partir de uma aplicação móvel ou cliente não fidedigno, pode iniciar a sessão com o seu nome de utilizador e a palavra-passe alternativa gerada aqui."
},
"changePasswordAction": "Alterar Palavra-passe",
@@ -179,13 +181,13 @@
"removeApiToken": {
"title": "Deseja remover o código {{ name }}?"
},
"passwordRecoveryEmail": "Mensagem de recuperação da palavra-passe"
"passwordRecoveryEmail": "Mensagem de Recuperação da Palavra-passe"
},
"users": {
"exposedLdap": {
"ipRestriction": {
"label": "Restringir Acesso",
"placeholder": "Endereço de IP ou Sub-rede separado por linha",
"placeholder": "Endereço de IP ou sub-redes separados por linha",
"description": "Limite o acesso do Servidor de Diretoria para IPs ou intervalos específicos. As linhas que começam com <code>#</code> são tratadas como comentários."
},
"secret": {
@@ -193,8 +195,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 +213,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",
@@ -232,7 +240,7 @@
},
"passwordResetDialog": {
"description": "A seguinte hiperligação de redefinir palavra-passe foi enviada para {{ email }}:",
"sendAction": "Enviar Mensagem",
"sendAction": "Enviar mensagem",
"reset2FAAction": "Redefinir 2FA",
"title": "Redefinir palavra-passe para {{ username }}",
"descriptionLink": "Copiar hiperligação de redefinição da palavra-passe",
@@ -243,19 +251,19 @@
"title": "Editar utilizador {{ username }}"
},
"deleteGroupDialog": {
"description": "Este grupo tem {{ memberCount }} membro(s). Deseja remover este grupo?",
"description": "Este grupo tem {{ memberCount }} membro(s).<br/><br/>Eliminar grupo\"{{ name }}\"?",
"deleteAction": "Eliminar",
"title": "Eliminar grupo {{ name }}"
"title": "Eliminar Grupo"
},
"invitationDialog": {
"descriptionEmail": "Enviar hiperligação de convite",
"descriptionEmail": "Enviar Hiperligação de Convite",
"title": "Convidar {{ username }}",
"sendAction": "Enviar Mensagem",
"descriptionLink": "Copiar hiperligação de convite",
"sendAction": "Enviar mensagem",
"descriptionLink": "Hiperligação de Convite",
"description": "A seguinte hiperligação de convite foi enviada para {{ email }}:"
},
"externalLdap": {
"autocreateUsersOnLogin": "Criar utilizadores automaticamente ao iniciar a sessão",
"autocreateUsersOnLogin": "Criar Utilizadores Automaticamente ao Iniciar a Sessão",
"provider": "Fornecedor",
"server": "URL do Servidor",
"filter": "Filtro",
@@ -266,7 +274,7 @@
"configureAction": "Configurar",
"noopInfo": "A autenticação LDAP não está configurada.",
"title": "Ligar uma Diretoria Externa",
"acceptSelfSignedCert": "Aceitar certificado Auto Assinado",
"acceptSelfSignedCert": "Aceitar Certificado Auto Assinado",
"groupnameField": "Campo do Nome do Grupo",
"errorSelfSignedCert": "O servidor está a utilizar um certificado inválido ou assinado automaticamente.",
"description": "Esta definição sincronizará e autenticará os utilizadores e grupos de um servidor LDAP ou Active Directory externa. A sincronização é executada periodicamente, mas também pode ser acionada manualmente.",
@@ -294,15 +302,16 @@
},
"setGhostDialog": {
"password": "Palavra-passe Temporária",
"setPassword": "Definir Palavra-passe",
"setPassword": "Definir palavra-passe",
"generatePassword": "Gerar Palavra-passe",
"title": "Criar palavra-passe para se passar por {{ username }}",
"description": "Defina uma palavra-passe temporária para fazer iniciar a sessão em nome deste utilizador nas aplicações ou no painel. Esta palavra-passe é válida por 6 horas."
"title": "Fazer-se passar pelo Utilizador",
"description": "Defina uma palavra-passe temporária para iniciar a sessão em nome deste utilizador nas aplicações ou no painel. Esta palavra-passe é válida por 6 horas."
},
"settings": {
"saveAction": "Guardar",
"allowProfileEditCheckbox": "Permitir que os utilizadores editem o seu nome e e-mail",
"require2FACheckbox": "Requer que os utilizadores configurem 2FA"
"require2FACheckbox": "Requer que os utilizadores configurem 2FA",
"title": "Definições"
},
"addGroupDialog": {
"title": "Adicionar Grupo"
@@ -319,12 +328,12 @@
"addUserDialog": {
"title": "Adicionar Utilizador",
"addUserAction": "Adicionar Utilizador",
"sendInviteCheckbox": "Enviar agora uma mensagem de convite"
"sendInviteCheckbox": "Enviar mensagem de convite"
},
"invitationNotification": {
"body": "Mensagem enviada para {{ email }}"
},
"title": "Utilizadores e Grupos"
"title": "Utilizadores"
},
"login": {
"2faToken": "Código 2FA",
@@ -492,7 +501,6 @@
"title": "Eliminar Arquivo de {{appTitle}} ({{fqdn}})"
},
"configureBackupSchedule": {
"schedule": "Agendar",
"days": "Dias",
"hours": "Horas",
"retentionPolicy": "Política de Retenção",
@@ -503,10 +511,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 +524,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": {
+126 -113
View File
@@ -3,7 +3,7 @@
"title": "Мои приложения",
"noApps": {
"title": "У Вас ещё нет установленных приложений!",
"description": "Хотите что-нибудь установить? Начните с <a href=\"{{ appStoreLink }}\">Магазина приложений</a>"
"description": "Хотите что-нибудь установить? Начните с <a href=\"{{ appStoreLink }}\">Магазина приложений</a>."
},
"auth": {
"sso": "Войдите, используя учётную запись Cloudron",
@@ -21,8 +21,8 @@
"main": {
"offline": "Cloudron недоступен. Переподключение…",
"rebootDialog": {
"title": "Сервер будет перезагружен. Вы уверены?",
"description": "Используйте данную опцию, чтобы применить обновления безопасности, или если Вы испытываете проблемы с работой сервера. Все приложения и службы, запущенные в Cloudron в настоящее время, автоматически запустятся после завершения перезагрузки.",
"title": "Перезагрузить сервер",
"description": "Перезагружает сервер для применения обновлений или исправления непредвиденного поведения. Все приложения и сервисы будут перезапущены автоматически.",
"rebootAction": "Перезагрузить сейчас"
},
"logout": "Выйти",
@@ -124,7 +124,8 @@
"settings": {
"allowProfileEditCheckbox": "Разрешить пользователям редактировать своё имя и адрес электронной почты",
"require2FACheckbox": "Требовать от пользователей настройки 2FA",
"saveAction": "Сохранить"
"saveAction": "Сохранить",
"title": "Настройки"
},
"externalLdap": {
"description": "Эта настройка будет синхронизировать и аутентифицировать пользователей и группы с внешнего сервера LDAP или Active Directory. Синхронизация выполняется периодически, но также может быть запущена вручную.",
@@ -143,7 +144,7 @@
"groupFilter": "Фильтр группы",
"groupnameField": "Поле с именем группы",
"auth": "Авторизоваться",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа",
"autocreateUsersOnLogin": "Автоматически создавать пользователей при входе",
"syncAction": "Синхронизировать",
"configureAction": "Настроить",
"errorSelfSignedCert": "Сервер использует недействительный или самоподписанный сертификат.",
@@ -152,7 +153,7 @@
"addUserDialog": {
"sendInviteCheckbox": "Отправить приглашение на электронную почту",
"title": "Добавить пользователя",
"addUserAction": "Добавить пользователя"
"addUserAction": "Добавить"
},
"user": {
"fullName": "Полное имя",
@@ -169,17 +170,17 @@
"displayNamePlaceholder": "Необязательно. Если не указано, пользователь может указать во время регистрации"
},
"deleteUserDialog": {
"title": "Удалить пользователя {{ username }}",
"description": "После удаления, пользователь не сможет получить доступ к панели управления Cloudron или войти в какое-либо приложение. Учтите, что данный процесс не затрагивает данные пользователя внутри приложений.",
"title": "Удалить пользователя",
"description": "После удаления, пользователь не сможет получить доступ к панели управления Cloudron или войти в какое-либо приложение. Учтите, что данный процесс не затрагивает данные пользователя внутри приложений.<br/><br/>Удалить пользователя \"{{ username }}\" ?",
"deleteAction": "Удалить"
},
"editGroupDialog": {
"externalLdapWarning": "Данная группа синхронизируется с внешней LDAP директорией.",
"title": "Редактировать группу {{ name }}"
"title": "Редактировать группу"
},
"deleteGroupDialog": {
"description": "Данная группа содержит {{ memberCount }} участников. Вы точно хотите удалить её?",
"title": "Удалить группу {{ name }}",
"description": "Данная группа содержит {{ memberCount }} участников. <br/><br/>Удалить группу \"{{ name }}\"?",
"title": "Удалить группу",
"deleteAction": "Удалить"
},
"passwordResetDialog": {
@@ -206,13 +207,13 @@
},
"setGhostDialog": {
"description": "Установите временный пароль для доступа к приложениям и панели управления от имени данного пользователя. Такой пароль будет действовать 6 часов.",
"title": "Создайте пароль для обезличивания {{ username }}",
"title": "Вотйти от имени {{ username }}",
"password": "Временный Пароль",
"setPassword": "Установить пароль",
"generatePassword": "Сгенерировать пароль"
},
"editUserDialog": {
"title": "Редактировать пользователя {{ username }}",
"title": "Редактировать пользователя",
"externalLdapWarning": "Этот пользователь синхронизирован с внешним каталогом LDAP."
},
"addGroupDialog": {
@@ -221,7 +222,8 @@
"group": {
"name": "Имя",
"users": "Пользователи",
"addGroupAction": "Добавить группу"
"addGroupAction": "Добавить",
"allowedApps": "Разрешённые приложения"
},
"externalLdapDialog": {
"title": "Настроить LDAP"
@@ -238,7 +240,7 @@
"description": "Сервер LDAP позволяет внешним приложениям аутентифицировать пользователей с использованием Каталога пользователей Cloudron.",
"secret": {
"label": "Привязать пароль",
"description": "Все запросы LDAP должны быть идентифицированы при помощи данного секрета и уникального имени пользователя (DN) <i>{{ userDN }}</i>",
"description": "Аутентифицируйте запросы с помощью данного секрета и уникального имени пользователя (DN) <i>{{ userDN }}</i>",
"url": "URL сервера"
},
"cloudflarePortWarning": "Для доступа к LDAP серверу через домен панели управления проксирование Cloudflare должно быть выключено",
@@ -273,17 +275,17 @@
"title": "Пароли приложений",
"app": "Приложение",
"name": "Имя",
"noPasswordsPlaceholder": "Пароли приложений не созданы"
"noPasswordsPlaceholder": "Пароли приложений отсутствуют"
},
"title": "Профиль",
"primaryEmail": "Главный адрес электронной почты",
"passwordRecoveryEmail": "Адрес электронной почты для восстановления пароля",
"passwordRecoveryEmail": "Почта для восстановления пароля",
"language": "Язык",
"apiTokens": {
"title": "API Токены",
"name": "Имя",
"description": "Используйте эти персональные токены для аутентификации в <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>",
"noTokensPlaceholder": "API Токены не созданы",
"description": "Используйте эти персональные токены для аутентификации с <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>.",
"noTokensPlaceholder": "API Токены отсутствуют",
"lastUsed": "Последний раз использован",
"neverUsed": "никогда",
"scope": "Область",
@@ -298,41 +300,41 @@
"logoutAll": "Выйти из всех"
},
"changeEmail": {
"title": "Изменить главный адрес электронной почты",
"email": "Новый адрес электронной почты",
"password": ароль для подтверждения"
"title": "Изменить главный Email",
"email": "Новый Email",
"password": "Подтверждение паролем"
},
"changeFallbackEmail": {
"title": "Изменить пароль электронной почты восстановления"
"title": "Изменить пароль Email для восстановления"
},
"createAppPassword": {
"title": "Создать пароль приложения",
"title": "Добавить пароль приложения",
"name": "Имя пароля",
"app": "Приложение",
"description": "Используйте этот пароль для аутентификации в приложении:",
"generatePassword": "Сгенерировать пароль",
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности."
},
"createApiToken": {
"copyNow": "Пожалуйста, скопируйте сгенерированный API Токен. Он не будет показан снова из соображений безопасности.",
"title": "Создать API Токен",
"title": "Добавить API Токен",
"name": "Имя API Токена",
"description": "Новый API Токен:",
"generateToken": "Сгенерировать API Токен",
"access": "API доступ",
"allowedIpRanges": "Разрешённые диапазоны IP"
},
"changePasswordAction": "Изменить Пароль",
"changePasswordAction": "Изменить пароль",
"disable2FAAction": "Выключить 2FA",
"enable2FAAction": "Включить 2FA",
"passwordResetNotification": {
"body": "Письмо отправлено на адрес электронной почты {{ email }}"
},
"removeApiToken": {
"title": "Вы точно хотите удалить токен {{ name }}?"
"title": "Удалить API Токен",
"description": "Удалить API token \"{{ name }}\" ?"
},
"removeAppPassword": {
"title": "Вы точно хотите удалить пароль {{ name }}?"
"title": "Удалить пароль приложения",
"description": "Удалить пароль приложения \"{{ name }}\" ?"
}
},
"app": {
@@ -355,7 +357,7 @@
"description": "Обновления приложения устанавливаются периодически в соответствии с Расписанием обновлений."
},
"updates": {
"description": "Cloudron периодически проверяет <a href=\"https://cloudron.io\" target=\"_blank\">Магазин приложений</a>на наличие обновлений."
"description": "Cloudron периодически проверяет Магазин приложений на наличие обновлений."
}
},
"backups": {
@@ -363,7 +365,7 @@
"description": "Резервные копии - это полные снимки ваших приложений. Вы можете использовать резервные копии для восстановления работы приложения или для его клонирования.",
"title": "Резервные копии",
"time": "Создан",
"downloadConfigTooltip": "Скачать Конфигурацию",
"downloadConfigTooltip": "Скачать конфигурацию",
"cloneTooltip": "Клонировать",
"restoreTooltip": "Восстановить",
"createBackupAction": "Создать резервную копию",
@@ -384,12 +386,12 @@
"locationPlaceholder": "Оставьте пустым, чтобы использовать основной домен",
"location": "Расположение",
"redirections": "Переадресация",
"noRedirections": "Не настроено ни одного домена для переадресации.",
"noRedirections": "Не настроено ни одного домена для переадресации",
"addRedirectionAction": "Добавить переадресацию",
"saveAction": "Сохранить",
"aliases": "Псевдонимы",
"addAliasAction": "Добавить псевдоним",
"noAliases": "Не настроено ни одного домена-псевдонима.",
"noAliases": "Не настроено ни одного домена-псевдонима",
"dnsoverwrite": "Некоторые DNS записи уже существуют. Подтвердите перезапись."
},
"accessControl": {
@@ -535,15 +537,14 @@
},
"robots": {
"title": "Robots.txt",
"disableIndexingAction": "Отключить индексирование",
"txtPlaceholder": "Оставьте пустым, чтобы позволить поисковым ботам индексировать приложение"
"disableIndexingAction": "Отключить индексирование"
},
"hstsPreload": "Активировать предзагрузку HSTS для этого сайта и всех поддоменов"
"hstsPreload": "Активировать предзагрузку HSTS (в том числе для поддоменов)"
},
"repair": {
"recovery": {
"title": "Режим восстановления",
"description": "Чтобы исправить сломанные плагины или неправильно внесённые изменения в конфигурацию, поместите приложение в <a href=\"{{ docsLink }}\" target=\"_blank\">Режим восстановления</a>.",
"description": "Чтобы исправить сломанные плагины или неправильно внесённые изменения в конфигурацию, поместите приложение в Режим восстановления.",
"restartAction": "Запустить повторно",
"disableAction": "Деактивировать режим восстановления",
"enableAction": "Активировать режим восстановления"
@@ -581,7 +582,6 @@
"title": "Импортировать резервную копию",
"importAction": "Импортировать",
"uploadAction": "загрузить Конфигурацию Резервной копии",
"description": "Любые данные, созданные между настоящим моментом и последней известной резервной копией будут безвозвратно утеряны. Рекомендуем создать резервную копию текущих данных перед импортом.",
"remotePath": "Путь резервной копии",
"provideBackupInfo": "Предоставьте информации о резервной копии для восстановления или"
},
@@ -634,7 +634,7 @@
},
"archive": {
"action": "Архивировать",
"latestBackupInfo": "Последняя резервная копия создана {{date}}.",
"latestBackupInfo": "Последняя резервная копия в {{siteName}} создана {{date}}.",
"title": "Архив",
"description": "Последняя резервная копия будет добавлена в <a href=\"/#/backups\">Архив</a>. Приложение будет удалено, но его можно восстановить из интерфейса Резервного копирования. Другие резервные копии будут очищены в соответствии с политикой резервного копирования.",
"noBackup": "Приложение не имеет резервных копий. Архивирование требует по крайней мере одну резервную копию."
@@ -661,23 +661,23 @@
"appCount": "{{ appCount }} Приложений",
"contents": "Содержит",
"noApps": "Приложения отсутствуют",
"tooltipDownloadBackupConfig": "Скачать Конфигурацию",
"tooltipDownloadBackupConfig": "Скачать конфигурацию",
"cleanupBackups": "Очистить резервные копии",
"backupNow": "Создать копию",
"tooltipPreservedBackup": "Резервная копия будет сохранена"
},
"schedule": {
"title": "Расписание & Политика хранения",
"title": "Расписание & политика хранения",
"schedule": "Расписание",
"retentionPolicy": "Политика хранения"
},
"configureBackupSchedule": {
"scheduleDescription": "Установите дни и часы для резервного копирования. Убедитесь, что установленное расписание не пересекается с <a href=\"/#/settings\">расписанием обновлений</a>.",
"schedule": "Расписание",
"days": "Дни",
"hours": "Часы",
"retentionPolicy": "Политика хранения",
"title": "Настроить расписание и хранение резервных копий"
"title": "Настроить расписание и хранение резервных копий",
"enable": "Включить автоматическое резервное копирование",
"disable": "Отключить автоматическое резервное копирование"
},
"configureBackupStorage": {
"encryptionPassword": "Пароль шифрования",
@@ -692,8 +692,8 @@
"bucketName": "Имя корзины",
"prefix": "Префикс",
"region": "Регион",
"s3AccessKeyId": "ID ключа доступа",
"s3SecretAccessKey": "Секрет ключа доступа",
"s3AccessKeyId": "Access Key ID",
"s3SecretAccessKey": "Secret Access Key",
"gcsServiceKey": "Ключ сервисного аккаунта",
"format": "Формат хранилища",
"memoryLimit": "Лимит памяти",
@@ -705,7 +705,6 @@
"downloadConcurrencyDescription": "Количество файлов, загружаемых одновременно во время восстановления",
"uploadConcurrencyDescription": "Количество файлов, выгружаемых одновременно во время резервного копирования",
"copyConcurrency": "Многопоточное копирование",
"copyConcurrencyDigitalOceanNote": "Лимит запросов DigitalOcean Spaces равен 20.",
"encryptionPasswordPlaceholder": "Парольная фраза, используемая для расшифровки резервных копий",
"encryptionPasswordRepeat": "Повторите пароль",
"server": "IP сервера или Имя хоста",
@@ -719,7 +718,7 @@
"uploadPartSizeDescription": "Размер одной части копии, состоящей из нескольких частей. До 3-х частей загружаются параллельно и требуют больше выделенной памяти.",
"copyConcurrencyDescription": "Количество удаленных копий файлов, выгружаемых одновременно во время резервного копирования.",
"password": "Пароль",
"cifsSealSupport": "Использовать SEAL шифрование. Требуется SMB v3",
"cifsSealSupport": "Использовать SEAL шифрованиеребуется SMB v3 и выше)",
"chown": "Удалённая файловая система поддерживает chown",
"encryptFilenames": "Шифровать имена файлов",
"preserveAttributesLabel": "Сохранить атрибуты файла",
@@ -746,7 +745,9 @@
"id": "Id",
"date": "Дата",
"version": "Версия",
"list": "Ссылается на резервные копии {{ appCount }} приложений"
"list": "Ссылается на резервные копии {{ appCount }} приложений",
"size": "Размер",
"duration": "Продолжительность"
},
"backupEdit": {
"title": "Редактировать резервную копию",
@@ -761,15 +762,15 @@
"title": "Восстановить из Архива",
"restoreActionOverwrite": "Восстановить и перезаписать DNS",
"restoreAction": "Восстановить",
"description": "Это действие установит {{appId}} на выбранный адрес из резервной копии от {{creationTime}}."
"description": "{{appId}} восстановится на выбранный адрес из резервной копии от {{creationTime}}."
},
"archives": {
"title": "Архив приложений",
"info": "Информация"
},
"deleteArchiveDialog": {
"title": "Удаление Архива {{appTitle}} ({{fqdn}})",
"description": "После удаления, архивы будут очищены в соответствии с политикой резервного копирования."
"title": "Удалить архив",
"description": "После удаления, архивы очищаются в соответствии с политикой резервного копирования.<br/><br/>Удалить \"{{ appTitle }} ({{ appFqdn }})\"?"
},
"deleteArchive": {
"deleteAction": "Удалить"
@@ -804,9 +805,10 @@
"domains": {
"title": "Домены",
"outbound": "Только исходящие письма",
"stats": "Количество: {{ mailboxCount }} / Используется: {{ usage }}",
"stats": "Почтовые ящики: {{ mailboxCount }} / Используется: {{ usage }}",
"disabled": "Выключено",
"testEmailTooltip": "Отправить тестовое письмо"
"testEmailTooltip": "Отправить тестовое письмо",
"inbound": "Входящие & Исходящие"
},
"settings": {
"title": "Настройки",
@@ -857,7 +859,7 @@
"customRules": "Пользовательские правила Spamassassin",
"blacklisteAddressesPlaceholder": "Шаблоны адресов электронной почты, разделенные строками",
"customRulesPlaceholder": "Пользовательские правила Spamassassin",
"blacklisteAddresses": "Адреса в листе блокировки",
"blacklisteAddresses": "Лист блокировки Email адресов",
"blacklisteAddressesInfo": "Подходящие адреса будут попадать в папку Спам. Поддерживаются '*' и '?' шаблоны glob."
},
"testMailDialog": {
@@ -886,7 +888,7 @@
"network": {
"ip": {
"title": "IPv4",
"description": "Этот IPv4 адрес используется для создания DNS-записей типа А.",
"description": "IPv4 адрес используется для DNS-записей типа А.",
"provider": "Провайдер",
"interface": "Имя сетевого интерфейса",
"configure": "Настроить",
@@ -901,16 +903,16 @@
"blocklist": "{{ blockCount }} IP заблокировано",
"configure": {
"title": "Настройки межсетевого экрана",
"description": "Подходящие адреса не смогут подключиться к серверу, а также панели мониторинга, почтовому серверу и всем установленным приложениям. Будьте осторожны, чтобы не заблокировать себе доступ.",
"description": "Соответствующим IP-адресам будет ограничен доступ к серверу, включая почтовый сервер, панель управления и все приложения. Убедитесь, что ваш собственный IP-адрес не будет заблокирован.",
"blocklistPlaceholder": "IP адреса или подсети, разделённые строками"
}
},
"dyndns": {
"title": "Динамический DNS",
"description": "Включите эту опцию, чтобы синхронизировать все ваши DNS-записи с изменяющимся IP-адресом. Это полезно, когда Cloudron работает в сети с часто меняющимся общедоступным IP-адресом, например, в домашних сетях."
"description": "Синхронизирует DNS записи с изменяющимся публичным IP-адресом. Полезно, когда Cloudron работает в сетях с регулярно изменяющимся IP, таких как домашняя сеть."
},
"configureIp": {
"title": "Настроить поставщика IPv4",
"title": "Настроить IPv4",
"providerGenericDescription": "Публичный IP адрес сервера будет обнаружен автоматически."
},
"ipv4": {
@@ -919,15 +921,15 @@
"ipv6": {
"address": "IPv6 адрес",
"title": "IPv6",
"description": "Этот IPv6-адрес используется для создания DNS-записей типа AAAA."
"description": "IPv6-адрес используется для DNS-записей типа AAAA."
},
"configureIpv6": {
"title": "Настройка IPv6"
"title": "Настроить IPv6"
},
"trustedIps": {
"summary": "{{ trustCount }} IP доверены",
"title": "Настроить доверенные IP",
"description": "HTTP заголовки от совпадающих IP адресов будут доверены"
"description": "HTTP заголовки от совпадающих IP адресов будут доверены."
},
"trustedIpRanges": "Доверенные IP и диапазоны "
},
@@ -941,8 +943,7 @@
"configure": {
"title": "Настроить {{ name }}",
"resetToDefaults": "Сбросить к стандартным настройкам",
"enableRecoveryMode": "Включить режим восстановления",
"recoveryModeDescription": "Если служба постоянно перезапускается или не отвечает из-за ошибки в данных, переведите её в режим восстановления. Ознакомьтесь со следующими <a href=\"{{ docsLink }}\" target=\"_blank\">инструкциями</a>, чтобы восстановить работу службы."
"enableRecoveryMode": "Включить режим восстановления"
}
},
"settings": {
@@ -973,9 +974,10 @@
"checkForUpdatesAction": "Проверить обновления",
"updateAvailableAction": "Доступно Обновление",
"stopUpdateAction": "Остановить обновление",
"description": "Обновления платформы и приложений запускаются на основании расписания и в соответствии с <a href=\"/#/system-locale\">системным часовым поясом</a>.",
"schedule": "Расписание",
"disabled": "Выключено"
"description": "Обновления платформы и приложений запускаются на основании установленного расписания и в соответствии с <a href=\"/#/system-settings\">системным часовым поясом</a>.",
"schedule": "Расписание обновлений",
"disabled": "Выключено",
"onLatest": "последний"
},
"updateScheduleDialog": {
"title": "Настроить расписание автоматических обновлений",
@@ -984,7 +986,7 @@
"selectOne": "Выберите по крайней мере один день и время",
"days": "Дни",
"hours": "Часы",
"description": "Установите дни и часы, в которые будет происходить автоматическое обновление платформы и приложений. Убедитесь, что установленное расписание не пересекается с <a href=\"/#/backups\">расписанием резервного копирования</a>."
"description": "Установите дни и часы, в которые будет происходить автоматическое обновление платформы и приложений. Убедитесь, что установленное расписание не пересекается с расписанием резервного копирования."
},
"updateDialog": {
"title": "Обновить Cloudron до",
@@ -1053,16 +1055,16 @@
"provider": "Провайдер",
"renewCerts": {
"title": "Обновление сертификатов",
"description": "Сертификаты Let’s Encrypt перевыпускаются автоматически. Используйте данную опцию, чтобы запустить перевыпуск прямо сейчас.",
"renewAllAction": "Обновить сертификаты"
"description": "Сертификаты Let’s Encrypt перевыпускаются автоматически. При необходимости, вы можете сделать это вручную.",
"renewAllAction": "Обновить все сертификаты"
},
"changeDashboardDomain": {
"title": "Домен панели управления",
"changeAction": "Изменить домен",
"description": "Данное действие переместит панель управления на <code>my</code> поддомен выбранного домена."
"description": "Перемещает панель управления на поддомен \"my\" выбранного домена."
},
"domainDialog": {
"editTitle": "Настроить {{ domain }}",
"editTitle": "Редактировать домен",
"domain": "Домен",
"provider": "Провайдер DNS",
"route53AccessKeyId": "ID ключа доступа",
@@ -1080,12 +1082,11 @@
"nameComApiToken": "API Токен",
"namecheapUsername": "Имя пользователя Namecheap",
"namecheapApiKey": "API Ключ",
"manualInfo": "Все записи DNS должны быть прописаны вручную до установки любого приложения.",
"manualInfo": "Все записи DNS должны быть прописаны вручную до установки приложения",
"letsEncryptInfo": "Для Lets Encrypt требуется, чтобы Ваш сервер был доступен через 80 порт",
"advancedAction": "Расширенные настройки…",
"zoneName": "Имя зоны (Необязательно)",
"addTitle": "Добавить домен",
"addDescription": "Добавление домена позволяет устанавливать приложения на его поддомены. Настроить электронную почту для выбранного домена можно разделе Электронная почта.",
"linodeToken": "Токен Linode",
"namecheapInfo": "IP-адрес сервера должен быть внесён в список разрешённых ключей для этого ключа API",
"wildcardInfo": "Вручную настройте A (IPv4) и AAAA (IPv6) DNS записи для <b>*.{{ domain }}.</b> и <b>{{ domain }}.</b> на IP-адрес данного сервера",
@@ -1118,15 +1119,16 @@
"gandiTokenTypePAT": "Персональный токен доступа (PAT)",
"inwxUsername": "Имя пользователя",
"inwxPassword": "Пароль",
"customNameservers": "Домен использует пользовательские серверы имён"
"customNameservers": "Домен использует пользовательские (Vanity) серверы имён"
},
"removeDialog": {
"title": "Действительно удалить {{ domain }}?",
"removeAction": "Удалить"
"title": "Удалить домен",
"removeAction": "Удалить",
"description": "Удалить домен \"{{ domain }}\" ?"
},
"syncDns": {
"title": "Синхронизация DNS",
"description": "Данное действие приведёт к повторной проверке записей DNS приложений и электронной почты на всех доменах.",
"description": "Обновляет DNS записи приложений и электронной почты для всех доменов.",
"syncAction": "Синхронизировать DNS"
},
"domainWellKnown": {
@@ -1134,7 +1136,8 @@
},
"tooltipWellKnown": "Общеизвестные расположения",
"emptyPlaceholder": "Домены отсутствуют",
"noMatchesPlaceholder": "Подходящий домен отсутствует"
"noMatchesPlaceholder": "Подходящий домен отсутствует",
"description": "Добавление домена позволит вам устанавливать приложения на его субдомены."
},
"notifications": {
"dismissTooltip": "Отклонить",
@@ -1152,7 +1155,7 @@
},
"allCaughtUp": "Уведомления отсутствуют",
"settingsDialog": {
"description": "Для выбранных событий уведомления будут отправляться на главный адрес электронной почты."
"description": "Для выбранных событий уведомления будут отправляться на главный email."
}
},
"logs": {
@@ -1302,7 +1305,6 @@
"setupDnsCheckbox": "Установить почтовые DNS записи",
"enableAction": "Включить",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Рекомендуем ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Почтовый домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен режим <code>только DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"setupDnsInfo": "Используйте данную опцию, чтобы автоматически настроить относящиеся к электронной почте записи DNS. Вы можете не отмечать её сразу, чтобы предварительно создать почтовые ящики и <a href=\"{{ importEmailDocsLink }}\">импортировать письма</a>."
},
"incoming": {
@@ -1317,7 +1319,8 @@
"usage": "Использовано",
"aliases": "Псевдонимы",
"emptyPlaceholder": "Почтовые ящики отсутствуют",
"noMatchesPlaceholder": "Подходящие почтовые ящики отсутствуют"
"noMatchesPlaceholder": "Подходящие почтовые ящики отсутствуют",
"stats": "Количество: {{ mailboxCount }} / Используется: {{ usage }}"
},
"title": "Входящие письма",
"sieveServerInfo": "Сервис ManageSieve",
@@ -1343,7 +1346,7 @@
"description": "Получать входящие письма для этого домена."
},
"config": {
"title": "Конфигурация электронной почты {{ domain }}",
"title": "Конфигурация электронной почты {{ domain }}",
"clientConfiguration": "Настройка почтовых клиентов",
"sending": {
"title": "Отправка"
@@ -1352,12 +1355,8 @@
"title": "Получение"
}
},
"masquerading": {
"title": "Маскировка",
"description": "Маскировка позволяет пользователям и приложениям отправлять письма с произвольным именем отправителя в поле «ОТ»."
},
"signature": {
"title": "Подпись",
"title": "Email подпись",
"plainTextFormat": "Обычный текст",
"htmlFormat": "Формат HTML",
"description": "Данный текст будет прикреплён ко всем письмам, отправляемым с выбранного домена."
@@ -1385,12 +1384,12 @@
"addAnotherAliasAction": "Добавить ещё один псевдоним",
"aliases": "Псевдонимы",
"noAliases": "Псевдонимы не настроены.",
"enableStorageQuota": "Включить квоту хранилища"
"enableStorageQuota": "Квота хранилища"
},
"deleteMailboxDialog": {
"title": "Удалить почтовый ящик {{ name }}@{{ domain }}",
"deleteAction": "Удалить",
"purgeMailboxCheckbox": "Удалить все письма и фильтры внутри почтового ящика",
"purgeMailboxCheckbox": "Удалить все письма и фильтры внутри этого почтового ящика",
"description": "После удаления, письма, отправленные на данный почтовый ящик, будут возвращаться отправителю с ошибкой. Вы можете не удалять содержимое почтовых ящиков в архивных целях. Они будут храниться на сервере по пути <code> /home/yellowtent/boxdata/mail/vmail</code>."
},
"addMailinglistDialog": {
@@ -1405,16 +1404,20 @@
"description": "Вы действительно хотите удалить список рассылки <b>{{ name }}@{{ domain }}</b>?"
},
"updateMailboxDialog": {
"enablePop3": "Включить POP3",
"activeCheckbox": "Почтовый ящик активен"
"enablePop3": "Доступ по протоколу POP3",
"activeCheckbox": "Активный почтовый ящик"
},
"editMailinglistDialog": {
"title": "Редактировать список рассылки {{ name }}@{{ domain }}"
},
"updateMailinglistDialog": {
"activeCheckbox": "Список рассылки активен"
"activeCheckbox": "Активный список рассылки"
},
"howToConnectInfoModal": "Настройка почтовых клиентов"
"howToConnectInfoModal": "Настройка почтовых клиентов",
"customFrom": {
"title": "Разрешить кастомного отправителя",
"description": "Разрешить авторизованным пользователям и приложениям отправлять письма от лица любого отправителя."
}
},
"login": {
"password": "Пароль",
@@ -1423,7 +1426,8 @@
"2faToken": "2FA Токен",
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже"
"errorInternal": "Внутренняя ошибка, попробуйте позже",
"loginAction": "Войти"
},
"passwordReset": {
"title": "Сброс пароля",
@@ -1461,15 +1465,17 @@
"privateKey": "Приватный SSH ключ"
},
"removeVolumeDialog": {
"removeAction": "Удалить"
"removeAction": "Удалить",
"title": "Удалить том",
"description": "Удалить том \"{{ volumeName }}\"?"
},
"mountType": "Тип монтирования",
"remountActionTooltip": "Смонтировать повторно",
"title": "Тома",
"description": "Тома - локальные или удаленные файловые системы. Они могут быть использованы для хранения данных приложений или для создания общей директории для нескольких приложений.",
"description": "Тома - локальные или удаленные файловые системы. Они могут быть использованы в качестве главного файлового хранилища приложения или в качестве общего между несколькими приложениями.",
"localDirectory": "Локальный каталог",
"editVolumeDialog": {
"title": "Редактирование тома {{ name }}"
"title": "Редактировать том"
},
"emptyPlaceholder": "Тома отсутствуют"
},
@@ -1522,7 +1528,7 @@
},
"storage": {
"mounts": {
"description": "Смонтированные <a href=\"/#/volumes\">тома</a> станут доступны в <code>/media/(имя тома)</code>. Смонтированные данные не попадают в резервные копии приложений."
"description": "Смонтированные тома станут доступны в <code>/media/(имя тома)</code>. Смонтированные данные не попадают в резервные копии приложений."
}
},
"oidc": {
@@ -1535,15 +1541,16 @@
"id": "ID Клиента",
"secret": "Секрет",
"signingAlgorithm": "Метод подписи",
"loginRedirectUri": "URL обратного вызова (разделённые запятой)"
"loginRedirectUri": "URLs обратного вызова",
"loginRedirectUriPlaceholder": "URLs адреса, разделённые запятой"
},
"description": "Провайдер OpenID может быть использован внешними приложениями для SSO аутентификации.",
"description": "Провайдер OpenID Cloudron может быть использован для единого входа во внешние приложения.",
"editClientDialog": {
"title": "Редактировать клиента {{ client }}"
"title": "Редактировать клиент"
},
"deleteClientDialog": {
"title": "Вы точно хотите удалить клиента {{ client }}?",
"description": "Удаление этого клиента OIDC приведет к аннулированию всех токенов доступа. Приложения, использующие этот клиент OIDC, больше не смогут проходить аутентификацию."
"title": "Удалить клиент",
"description": "После удаления все токены доступа, выданные этому клиенту, будут аннулированы. Приложения, использующие этот клиент OIDC, больше не смогут проходить аутентификацию.<br/><br/>Удалить клиент \"{{ clientName }}\"?"
},
"env": {
"discoveryUrl": "URL обнаружения"
@@ -1551,6 +1558,10 @@
"clients": {
"title": "Клиенты OpenID",
"empty": "Клиенты OpenID отсутствуют"
},
"clientCredentials": {
"title": "Учётные данные клиента",
"description": "Скопировать учётные данные для клиента \"{{ clientName }}\"."
}
},
"userdirectory": {
@@ -1566,7 +1577,8 @@
"backup": {
"target": {
"label": "Локация резервных копий",
"size": "Размер"
"size": "Размер",
"fileCount": "Файлы"
},
"sites": {
"title": "Локации резервных копий",
@@ -1585,20 +1597,21 @@
"provider": "Провайдер",
"username": "Имя пользователя",
"title": "Реестры Docker",
"description": "Cloudron может выполнять команду pull и установку <a href=\"{{ customAppsLink }}\" target=\"_blank\">сторонних приложений</a> из частного реестра docker.",
"description": "Настроить доступ к приватным Docker реестрам для установки сторонних приложений.",
"removeDialog": {
"title": "Удалить {{ serverAddress }}"
"title": "Удалить Реестр Docker"
},
"email": "Email",
"passwordToken": "Пароль/Токен",
"emptyPlaceholder": "Реестры Docker отсутствуют",
"dialog": {
"title": "Реестр Docker"
},
"emptyPlaceholder": "Реестры Docker отсутствуют"
"addTitle": "Добавить Реестр Docker",
"editTitle": "Редактировать Реестр Docker"
}
},
"dockerRegistres": {
"removeDialog": {
"description": "Действительно удалить этот реестр?"
"description": "Удалить Реестр Docker \"{{ serverAddress }}\"?"
}
},
"appearance": {
@@ -1609,7 +1622,7 @@
},
"externallinks": {
"label": "Внешние ссылки",
"description": "Добавляет ярлык на внешние сервисы в панель управления"
"description": "Добавляет ярлык на внешние сервисы в панель управления."
},
"server": {
"title": "Сервер"
-14
View File
@@ -258,7 +258,6 @@
"title": "Tạo mật khẩu app",
"name": "Tên cho mật khẩu",
"app": "App",
"generatePassword": "Tạo mật khẩu",
"copyNow": "Xin copy mật khẩu này bây giờ. Nó sẽ không được hiển thị lại vì lý do an ninh.",
"description": "Sử dụng mật khẩu sau để xác minh cho app:"
},
@@ -266,7 +265,6 @@
"title": "Tạo mã API",
"description": "Mã API mới:",
"copyNow": "Xin copy mã API này bây giờ. Nó sẽ không được hiển thị lại vì lý do an ninh.",
"generateToken": "Tạo mã API",
"name": "Tên cho mã API",
"access": "Truy cập API",
"allowedIpRanges": "Dãy IP cho phép"
@@ -330,7 +328,6 @@
"memoryLimitDescription": "Giới hạn bộ nhớ cho thao tác sao lưu. Điều chỉnh nếu bạn cần tăng giới hạn hiện tại so với giá trị mặc định.",
"encryptionPasswordRepeat": "Nhập lại mật khẩu",
"encryptionPasswordPlaceholder": "Mật khẩu để mã hoá các bản sao lưu",
"copyConcurrencyDigitalOceanNote": "DigitalOcean Spaces giới hạn ở mức 20.",
"copyConcurrency": "Copy đồng thời",
"uploadConcurrencyDescription": "Số tập tin để tải lên cùng lúc khi đang sao lưu Cloudron",
"downloadConcurrency": "Tải xuống đồng thời",
@@ -374,8 +371,6 @@
"retentionPolicy": "Thời gian lưu giữ",
"hours": "Thời gian",
"days": "Ngày",
"scheduleDescription": "Chọn ngày và giờ mà Cloudron sẽ thực hiện sao lưu. Xin lưu ý tránh chọn thời gian trùng với <a href=\"/#/settings\">lịch cập nhật phiên bản Cloudron</a>.",
"schedule": "Lịch sao lưu",
"title": "Cấu hình lịch sao lưu và thời gian lưu giữ"
},
"backupDetails": {
@@ -466,7 +461,6 @@
"enableAction": "Bật",
"setupDnsInfo": "Sử dụng lựa chọn này để cài đặt những bản ghi có liên quan đến email. Để trống lựa chọn này sẽ hữu ích cho việc tạo ra các hộp thư và <a href=\"{{ importEmailDocsLink }}\">nhập dữ liệu các mail đã có sẵn</a> trước khi đưa vào sử dụng.",
"setupDnsCheckbox": "Cài đặt các bản ghi DNS ngay",
"cloudflareInfo": "Tên miền cho mail server <code>{{ adminDomain }}</code> được quản lý bởi Cloudflare. Hãy nhớ tắt proxy qua Cloudflare cho <code>{{ mailFqdn }}</code> và chỉnh về chế độ <code>DNS only</code>. Cần làm vậy vì Cloudflare không proxy được email.",
"noProviderInfo": "Chưa cài đặt nhà cung cấp DNS. Những bản ghi DNS trong phần Trạng thái cần được cài đặt thủ công.",
"description": "Lựa chọn này sẽ cấu hình Cloudron để nhận mail cho <b>{{ domain }}</b>. Xem hướng dẫn để mở <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">những cổng cần thiết</a> cho Email Cloudron.",
"title": "Bật chế độ email cho {{ domain }}?"
@@ -583,10 +577,6 @@
"description": "Phần chữ này sẽ được gắn thêm vào phía cuối mail gửi đi từ tên miền này.",
"title": "Chữ ký cuối mail"
},
"masquerading": {
"description": "Việc cài đặt mặt nạ mail cho phép người dùng và app gửi mail với một tên gọi khác tuỳ chọn cho địa chỉ mail GỬI TỪ (FROM).",
"title": "Mặt nạ email"
},
"updateMailboxDialog": {
"activeCheckbox": "Hộp thư đang hoạt động",
"enablePop3": "Bật truy cập POP3"
@@ -831,7 +821,6 @@
"configure": {
"resetToDefaults": "Chỉnh về mặc định",
"title": "Cấu hình {{ name }}",
"recoveryModeDescription": "Nếu những dịch vụ đang chạy liên tục bị khởi động lại hoặc không có tín hiệu phản hồi vì gián đoạn thông tin, hãy cho dịch vụ vào chế độ phục hồi. Hãy dùng <a href=\"{{ docsLink }}\" target=\"_blank\">những hướng dẫn sau đây</a> để khởi chạy dịch vụ lại lần nữa.",
"enableRecoveryMode": "Bật chế độ phục hồi"
},
"restartActionTooltip": "Khởi động lại",
@@ -1014,7 +1003,6 @@
"route53AccessKeyId": "Mã access",
"provider": "Nhà cung cấp DNS",
"domain": "Tên miền",
"addDescription": "Thêm tên miền cho phép bạn cài đặt app trên những tên miền con. Cài đặt mail cho tên miền có thể được tuỳ chỉnh trên mục Email.",
"editTitle": "Cấu hình {{ domain }}",
"addTitle": "Thêm tên miền",
"wellKnownDescription": "Những giá trị nhập vào này sẽ được dùng bởi Cloudron để phản hồi về những đường link <code>/.well-known/</code>. Lưu ý rằng một app cần được đang chạy cài đặt sẵn trên tên miền gốc <code>{{ domain }}</code> để tính năng này có thể hoạt động được. Xem phần <a href=\"{{docsLink}}\" target=\"_blank\">hướng dẫn sử dụng</a> để biết thêm thông tin.",
@@ -1127,7 +1115,6 @@
"security": {
"robots": {
"disableIndexingAction": "Không cho lên chỉ mục",
"txtPlaceholder": "Để trống để cho tất cả bot lên chỉ mục app này",
"title": "File Robots.txt"
},
"csp": {
@@ -1285,7 +1272,6 @@
"importBackupDialog": {
"importAction": "Nhập vào",
"uploadAction": "Tải lên cấu hình bản sao lưu",
"description": "Những dữ liệu được tạo ra tính từ thời điểm này và lần sao lưu cuối cùng sẽ bị mất vĩnh viễn. Bạn nên tạo một bản sao lưu của những dữ liệu hiện tại trước khi thực hiện việc nhập vào.",
"title": "Nhập bản sao lưu vào",
"remotePath": "Đường dẫn bản sao lưu"
},
+3 -16
View File
@@ -28,15 +28,13 @@
"name": "密码名称",
"app": "应用",
"description": "使用下面的密码来登录该应用:",
"copyNow": "请复制这个密码。出于安全考虑,这个密码以后无法再显示。",
"generatePassword": "生成密码"
"copyNow": "请复制这个密码。出于安全考虑,这个密码以后无法再显示。"
},
"createApiToken": {
"title": "创建 API Token",
"name": "API Token 名称",
"description": "新 API Token",
"copyNow": "请复制 API Token。出于安全考虑,这个 API Token 未来不会再显示。",
"generateToken": "生成 API Token"
"copyNow": "请复制 API Token。出于安全考虑,这个 API Token 未来不会再显示。"
},
"changePasswordAction": "修改密码",
"disable2FAAction": "停用双因素验证",
@@ -119,11 +117,9 @@
},
"configureBackupSchedule": {
"title": "配置备份计划和保留时间",
"scheduleDescription": "选择 Cloudron 备份的日期和时间。请注意这个安排不要和 <a href=\"/#/settings\">升级计划</a> 重合。",
"hours": "小时",
"days": "星期",
"retentionPolicy": "保留时间",
"schedule": "备份计划"
"retentionPolicy": "保留时间"
},
"configureBackupStorage": {
"title": "配置备份的存储",
@@ -155,7 +151,6 @@
"memoryLimitDescription": "备份任务的内存限制。如果您增加了并发值,请调整内存上限。",
"copyConcurrency": "并发数",
"copyConcurrencyDescription": "当备份时同时复制几个文件。",
"copyConcurrencyDigitalOceanNote": "DigitalOcean Spaces 的上限为 20。",
"s3LikeNote": "请不要在 S3 存储桶上设置 lifecycle 规则,因为这会导致 rsync 备份损坏。",
"server": "服务器 IP 或 Hostname",
"cifsSealSupport": "使用 seal 加密。需要 SMB v3 以上版本",
@@ -632,7 +627,6 @@
"fallbackCertCustomCertInfo": "这个<a href=\"{{ customCertLink }}\" target=\"_blank\">泛域名证书</a>会被用于该域名下的所有应用。如未提供,会使用一个自动生成的自签名证书。",
"fallbackCertKeyPlaceholder": "密钥",
"fallbackCertCertificatePlaceholder": "证书",
"addDescription": "添加一个域名后,您就可以在该域名的子域名中安装应用。域名的 Email 请在 Email 设置中配置。",
"cloudflareEmail": "Cloudflare Email",
"namecheapInfo": "这个服务器的 IP 需要被添加在 API Key 的白名单里。",
"wildcardInfo": "将 <b>*.{{ domain }}</b> 和 <b>{{ domain }}</b> 的 <i>A</i> 记录都指向这台服务器的 IP。",
@@ -791,7 +785,6 @@
"description": "此配置会使 Cloudron 为 <b>{{ domain }}</b> 收取邮件。请参考文档以为 Cloudron Email 开放 <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">所需要的端口</a>。",
"enableAction": "启用",
"noProviderInfo": "没有配置 DNS 提供商。请手动设置状态标签页下列出的 DNS 记录。",
"cloudflareInfo": "域名 <code>{{ adminDomain }}</code> 由 Cloudflare 管理。请确认 <code>{{ mailFqdn }}</code> 的 Cloudflare 代理已经关闭,并且设置为 <code>DNS only</code>。因为 Cloudflare 不会代理 Email。",
"setupDnsInfo": "使用此选项会自动设置 Email 相关的 DNS 记录。如果你需要在启用 Email 服务器之前创建邮箱、<a href=\"{{ importEmailDocsLink }}\">导入邮件</a>,请先不要选中这个选项。",
"setupDnsCheckbox": "现在设置邮件 DNS 记录"
},
@@ -816,10 +809,6 @@
"description": "下列文本会被附在所有从本域名发出的邮件的末尾。",
"title": "签名"
},
"masquerading": {
"description": "Masquerading 允许用户和应用在发送邮件时,在发件人一栏使用任意的用户名。",
"title": "Masquerading"
},
"outbound": {
"mailRelay": {
"spfDocInfo": "Cloudron 无法自动设置 SPF 记录。请按照 <a href=\"{{ spfDocsLink }}\" target=\"_blank\">{{ name }} 文档</a> 手动设置。",
@@ -990,7 +979,6 @@
},
"robots": {
"title": "Robots.txt",
"txtPlaceholder": "留空以允许所有 bots 爬取此应用",
"disableIndexingAction": "禁止爬取"
}
},
@@ -1067,7 +1055,6 @@
},
"importBackupDialog": {
"title": "导入备份",
"description": "从上次备份到当前状态之间产生的所有数据都会丢失。我们建议在导入数据之前为当前数据创建一个手动备份。",
"uploadAction": "上传备份配置文件",
"importAction": "导入"
},
+5 -3
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();
@@ -263,7 +266,6 @@ onMounted(async () => {
await refreshConfigAndFeatures();
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
if (document.querySelector('link[rel="icon"]')) document.querySelector('link[rel="icon"]').href = `${API_ORIGIN}/api/v1/cloudron/avatar?ts=${Date.now()}`;
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.hash = `/${VIEWS.PROFILE}?setup2fa`;
@@ -302,7 +304,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 +315,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>
+38 -32
View File
@@ -1,53 +1,59 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed } 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: {
type: Array,
required: true,
},
groups: {
type: Array,
required: true,
},
manifest: {
type: Object,
required: true,
},
sso: {
type: Boolean,
default: false,
required: false,
},
installation: {
type: Boolean,
required: true,
},
});
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;
const optionalSso = computed(() => {
return !!props.manifest.optionalSso && props.installation;
});
const cloudronAuth = computed(() => {
return !(!props.installation && !props.sso) && !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']);
});
</script>
<template>
<div>
<FormGroup v-show="manifest.addons.email">
<label>{{ $t('appstore.installDialog.userManagement') }}</label>
<div>
{{ $t('appstore.installDialog.userManagementMailbox') }}
<span v-html="$t('appstore.installDialog.configuredForCloudronEmail', { emailDocsLink: 'https://docs.cloudron.io/email/' })"></span>
</div>
<FormGroup>
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
<div v-if="!cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
<div v-if="manifest.addons.email" v-html="$t('appstore.installDialog.userManagementMailbox')"></div>
</FormGroup>
<FormGroup>
<label v-show="cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagement') }} <sup><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-show="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
<br/>
<label v-show="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-show="!cloudronAuth || manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
<FormGroup>
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.dashboardVisibility.description') }}</div>
<div style="padding-top: 10px">
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
+10 -6
View File
@@ -101,10 +101,12 @@ function onReset() {
async function onRevokeToken(apiToken) {
const yes = await inputDialog.value.confirm({
message: t('profile.removeApiToken.title', { name: apiToken.name }),
title: t('profile.removeApiToken.title'),
message: t('profile.removeApiToken.description', { name: apiToken.name }),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no')
confirmLabel: t('main.action.remove'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -128,9 +130,10 @@ onMounted(async () => {
<Dialog ref="newDialog"
:title="$t('profile.createApiToken.title')"
:confirm-label="addedToken ? '' : $t('profile.createApiToken.generateToken')"
:confirm-label="addedToken ? '' : $t('main.action.add')"
:confirm-active="isValid"
confirm-style="primary"
:reject-label="$t('main.dialog.close')"
:reject-label="addedToken ? $t('main.dialog.close') : $t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmitAddApiToken()"
@close="onReset()"
@@ -154,7 +157,8 @@ onMounted(async () => {
<FormGroup>
<label for="">{{ $t('profile.createApiToken.allowedIpRanges') }}</label>
<div class="has-error" v-show="tokenAllowedIpRangesError">{{ tokenAllowedIpRangesError }}</div>
<TextInput v-model="tokenAllowedIpRanges" :placeholder="$t('profile.apiTokens.allowedIpRangesPlaceholder')" />
<TextInput v-model="tokenAllowedIpRanges" />
<small class="helper-text">{{ $t('profile.apiTokens.allowedIpRangesPlaceholder') }}</small>
</FormGroup>
</form>
</div>
+89 -49
View File
@@ -2,7 +2,7 @@
import { ref, useTemplateRef } from 'vue';
import { Dialog, FormGroup, TextInput, PasswordInput, Checkbox } from '@cloudron/pankow';
import { s3like } from '../utils.js';
import { s3like, mountlike } from '../utils.js';
import BackupProviderForm from './BackupProviderForm.vue';
import AppsModel from '../models/AppsModel.js';
import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_WASABI } from '../constants.js';
@@ -31,80 +31,102 @@ async function onSubmit() {
busy.value = true;
let backupPath = remotePath.value;
const backupConfig = {};
const config = {};
// only set provider specific fields, this will clear them in the db
if (s3like(provider.value)) {
backupConfig.bucket = providerConfig.value.bucket;
backupConfig.prefix = providerConfig.value.prefix;
backupConfig.accessKeyId = providerConfig.value.accessKeyId;
backupConfig.secretAccessKey = providerConfig.value.secretAccessKey;
config.bucket = providerConfig.value.bucket;
config.prefix = providerConfig.value.prefix;
config.accessKeyId = providerConfig.value.accessKeyId;
config.secretAccessKey = providerConfig.value.secretAccessKey;
if (providerConfig.value.endpoint) backupConfig.endpoint = providerConfig.value.endpoint;
if (providerConfig.value.endpoint) config.endpoint = providerConfig.value.endpoint;
if (provider.value === 's3') {
if (providerConfig.value.region) backupConfig.region = providerConfig.value.region;
delete backupConfig.endpoint;
if (providerConfig.value.region) config.region = providerConfig.value.region;
delete config.endpoint;
} else if (provider.value === 'minio' || provider.value === 's3-v4-compat') {
backupConfig.region = providerConfig.value.region || 'us-east-1';
backupConfig.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
config.region = providerConfig.value.region || 'us-east-1';
config.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
config.s3ForcePathStyle = true; // might want to expose this in the UI
} else if (provider.value === 'exoscale-sos') {
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
config.region = 'us-east-1';
config.signatureVersion = 'v4';
} else if (provider.value === 'wasabi') {
backupConfig.region = REGIONS_WASABI.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_WASABI.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'scaleway-objectstorage') {
backupConfig.region = REGIONS_SCALEWAY.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_SCALEWAY.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'linode-objectstorage') {
backupConfig.region = REGIONS_LINODE.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_LINODE.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'ovh-objectstorage') {
backupConfig.region = REGIONS_OVH.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_OVH.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'ionos-objectstorage') {
backupConfig.region = REGIONS_IONOS.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_IONOS.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'vultr-objectstorage') {
backupConfig.region = REGIONS_VULTR.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_VULTR.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'contabo-objectstorage') {
backupConfig.region = REGIONS_CONTABO.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
config.region = REGIONS_CONTABO.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
config.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
} else if (provider.value === 'upcloud-objectstorage') {
const m = /^.*\.(.*)\.upcloudobjects.com$/.exec(providerConfig.value.endpoint);
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
backupConfig.signatureVersion = 'v4';
config.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
config.signatureVersion = 'v4';
} else if (provider.value === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
config.region = 'us-east-1';
} else if (provider.value === 'hetzner-objectstorage') {
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
config.region = 'us-east-1';
config.signatureVersion = 'v4';
}
} else if (mountlike(provider.value)) {
config.prefix = providerConfig.value.prefix;
config.noHardlinks = !providerConfig.value.useHardlinks;
config.mountOptions = {};
if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs') {
config.mountOptions.host = providerConfig.value.mountOptionHost;
config.mountOptions.remoteDir = providerConfig.value.mountOptionRemoteDir;
if (provider.value === 'cifs') {
config.mountOptions.username = providerConfig.value.mountOptionUsername;
config.mountOptions.password = providerConfig.value.mountOptionPassword;
config.mountOptions.seal = !!providerConfig.value.mountOptionSeal;
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
} else if (provider.value === 'sshfs') {
config.mountOptions.user = providerConfig.value.mountOptionUser;
config.mountOptions.port = parseInt(providerConfig.value.mountOptionPort);
config.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
config.preserveAttributes = true;
}
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
config.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
config.preserveAttributes = true;
} else if (provider.value === 'mountpoint') {
config.mountPoint = providerConfig.value.mountPoint;
config.chown = !!providerConfig.value.chown;
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
}
} else if (provider.value === 'gcs') {
backupConfig.bucket = providerConfig.value.bucket;
backupConfig.prefix = providerConfig.value.prefix;
backupConfig.projectId = providerConfig.value.projectId;
backupConfig.credentials = providerConfig.value.credentials;
} else if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs' || provider.value === 'ext4' || provider.value === 'xfs') {
backupConfig.mountOptions = providerConfig.value.mountOptions;
backupConfig.prefix = providerConfig.value.prefix;
} else if (provider.value === 'mountpoint') {
backupConfig.prefix = providerConfig.value.prefix;
backupConfig.mountPoint = providerConfig.value.mountPoint;
} else if (provider.value === 'filesystem') {
const parts = remotePath.value.split('/');
backupPath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
backupConfig.backupDir = parts.join('/'); // this is dirname()
config.backupDir = parts.join('/'); // this is dirname()
} else if (provider.value === 'gcs') {
config.bucket = providerConfig.value.bucket;
config.prefix = providerConfig.value.prefix;
config.projectId = providerConfig.value.projectId;
config.credentials = providerConfig.value.credentials;
}
const data = {
format: format.value,
provider: provider.value,
config: backupConfig,
config: config,
remotePath: backupPath
};
@@ -176,12 +198,27 @@ function onBackupConfigChanged(event) {
provider.value = data.provider;
remotePath.value = data.remotePath;
providerConfig.value = data.config;
format.value = data.format;
encrypted.value = !!data.encrypted;
encryptionPasswordHint.value = data.encryptionPasswordHint || '';
encryptionPassword.value = '';
encryptedFilenames.value = data.encryptedFilenames;
// translate for BackupProviderForm flattened object
providerConfig.value = {};
providerConfig.value.useHardlinks = !data.config.noHardlinks;
providerConfig.value.prefix = data.config.prefix;
providerConfig.value.chown = !!data.config.chown;
providerConfig.value.preserveAttributes = data.config.preserveAttributes;
providerConfig.value.mountOptionHost = data.config.mountOptions.host;
providerConfig.value.mountOptionPort = data.config.mountOptions.port;
providerConfig.value.mountOptionRemoteDir = data.config.mountOptions.remoteDir;
providerConfig.value.mountOptionSeal = !!data.config.mountOptions.seal;
providerConfig.value.mountOptionDiskPath = data.config.mountOptions.diskPath;
providerConfig.value.mountOptionUser = data.config.mountOptions.user;
providerConfig.value.mountOptionUsername = data.config.mountOptions.username;
providerConfig.value.mountOptionPassword = data.config.mountOptions.password;
providerConfig.value.mountOptionPrivateKey = '';
};
reader.readAsText(event.target.files[0]);
@@ -224,7 +261,10 @@ defineExpose({
@confirm="onSubmit()"
>
<div>
<div>{{ $t('app.importBackupDialog.description') }}</div>
<div class="text-danger">{{ $t('app.importBackupDialog.warning') }}</div>
<!-- ideally, we get can get rid of this and just display this error from the imported config -->
<p class="text-danger">{{ $t('app.importBackupDialog.versionMustMatchInfo') }}</p>
<p>{{ $t('app.importBackupDialog.provideBackupInfo') }}
<input type="file" ref="backupConfigFileInput" @change="onBackupConfigChanged" accept="application/json, text/json" style="display:none"/>
+16 -8
View File
@@ -7,8 +7,9 @@ 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 UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
const STEP = Object.freeze({
@@ -18,9 +19,11 @@ const STEP = Object.freeze({
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const dashboardModel = DashboardModel.create();
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
const dashboardDomain = inject('dashboardDomain');
// reactive
const busy = ref(false);
@@ -32,7 +35,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;
@@ -77,6 +79,8 @@ const udpPorts = ref({});
const secondaryDomains = ref({});
const upstreamUri = ref('');
const needsOverwriteDns = ref(false);
const users = ref([]);
const groups = ref([]);
function onDomainChange() {
const tmp = domains.value.find(d => d.domain === domain.value);
@@ -167,10 +171,14 @@ function onClose() {
}
onMounted(async () => {
const [error, result] = await dashboardModel.config();
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => u.username = u.username || u.email);
users.value = result;
dashboardDomain.value = result.adminDomain;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
});
const screenshotsContainer = useTemplateRef('screenshotsContainer');
@@ -212,7 +220,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 +239,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;
@@ -304,7 +312,7 @@ defineExpose({
</FormGroup>
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="formError" :domain-provider="domainProvider"/>
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest"/>
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest" :users="users" :groups="groups" :installation="true"/>
<div class="bottom-button-bar">
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
+7 -5
View File
@@ -108,10 +108,12 @@ async function onSubmit() {
async function onRemove(appPassword) {
const yes = await inputDialog.value.confirm({
message: t('profile.removeAppPassword.title', { name: appPassword.name }),
title: t('profile.removeAppPassword.title'),
message: t('profile.removeAppPassword.description', { name: appPassword.name }),
confirmLabel: t('main.action.remove'),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -162,9 +164,9 @@ onMounted(async () => {
<Dialog ref="newDialog"
:title="$t('profile.createAppPassword.title')"
:confirm-active="addedPassword || isValid"
:confirm-label="addedPassword ? '' : $t('profile.createAppPassword.generatePassword')"
:confirm-label="addedPassword ? '' : $t('main.action.add')"
confirm-style="primary"
:reject-label="$t('main.dialog.close')"
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
@close="onReset()"
@@ -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;
@@ -190,7 +191,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="appId ? $t('app.cloneDialog.title', { app: fqdn }) : $t('backups.restoreArchiveDialog.title')"
:title="appId ? $t('app.cloneDialog.title') : $t('backups.restoreArchiveDialog.title')"
reject-style="secondary"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
+7 -5
View File
@@ -98,8 +98,9 @@ async function onRemove() {
const yes = await inputDialog.value.confirm({
message: `Really remove applink?`,
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.cancel')
confirmLabel: t('main.action.remove'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -145,7 +146,7 @@ defineExpose({
alternate-style="danger"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-label="mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
:confirm-active="isValid"
:confirm-busy="busy"
@confirm="onSubmit()"
@@ -172,12 +173,13 @@ defineExpose({
<div>
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" size="512" display-height="80px" style="width: 80px"/>
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" :size="512" display-height="80px" style="width: 80px"/>
</div>
<FormGroup>
<label for="applinkTags">{{ $t('app.display.tags') }}</label>
<TagInput id="applinkTags" :placeholder="$t('app.display.tagsPlaceholder')" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
<TagInput id="applinkTags" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
<small class="helper-text">{{ $t('app.display.tagsPlaceholder') }}</small>
</FormGroup>
<FormGroup>
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, watch, watchEffect } from 'vue';
import { Button, InputGroup, SingleSelect, FormGroup, TextInput, Checkbox, PasswordInput, NumberInput } from '@cloudron/pankow';
import { BACKUP_FORMATS, STORAGE_PROVIDERS, REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_EXOSCALE, REGIONS_DIGITALOCEAN, REGIONS_HETZNER, REGIONS_WASABI, REGIONS_S3 } from '../constants.js';
import ProvisionModel from '../models/ProvisionModel.js';
@@ -100,6 +100,11 @@ watch(provider, (newProvider) => {
}
});
watchEffect(() => {
if (!providerConfig.value.mountOptionPrivateKey) return;
providerConfig.value.mountOptionPrivateKey = providerConfig.value.mountOptionPrivateKey.replaceAll('\\n', '\n');
});
onMounted(async () => {
await getBlockDevices();
});
@@ -113,7 +118,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 -->
@@ -125,13 +130,13 @@ onMounted(async () => {
<!-- CIFS/NFS/SSHFS -->
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }} ({{ provider }})</label>
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }}</label>
<TextInput id="mountOptionHostInput" v-model="providerConfig.mountOptionHost" placeholder="Server IP or hostname" required />
</FormGroup>
<!-- CIFS/NFS/SSHFS -->
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }} ({{ provider }})</label>
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }}</label>
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
</FormGroup>
@@ -140,13 +145,13 @@ onMounted(async () => {
<!-- CIFS -->
<FormGroup v-if="provider === 'cifs'">
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }} ({{ provider }})</label>
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }}</label>
<TextInput id="mountOptionUsernameInput" v-model="providerConfig.mountOptionUsername" required />
</FormGroup>
<!-- CIFS -->
<FormGroup v-if="provider === 'cifs'">
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }} ({{ provider }})</label>
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }}</label>
<PasswordInput id="mountOptionPasswordInput" v-model="providerConfig.mountOptionPassword" required />
</FormGroup>
@@ -178,7 +183,7 @@ onMounted(async () => {
<!-- SSHFS -->
<FormGroup v-if="provider === 'sshfs'">
<label for="mountOptionPrivateKeyInput">{{ $t('backups.configureBackupStorage.privateKey') }}</label>
<textarea id="mountOptionPrivateKeyInput" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
<textarea id="mountOptionPrivateKeyInput" rows="7" style="white-space: nowrap;" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
</FormGroup>
<!-- Filesystem -->
@@ -253,7 +258,7 @@ onMounted(async () => {
<input type="file" id="gcsKeyFileInput" style="display:none" accept="application/json, text/json" @change="onGcsKeyChange"/>
<label for="gcsKeyInput">{{ $t('backups.configureBackupStorage.gcsServiceKey') }}{{ providerConfig.projectId ? ` - project: ${providerConfig.projectId}` : '' }}</label>
<InputGroup>
<TextInput readonly required style="flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service Account Key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
<TextInput readonly required style="flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service account key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
<Button tool icon="fa fa-upload" onclick="document.getElementById('gcsKeyFileInput').click();"/>
</InputGroup>
<div class="error-label" v-show="gcsFileParseError">{{ gcsFileParseError }}</div>
@@ -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>
@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { ref, useTemplateRef, watch } from 'vue';
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
import { prettyBinarySize } from '@cloudron/pankow/utils';
import { s3like, mountlike, regionName } from '../utils.js';
@@ -38,6 +38,11 @@ const useHardlinks = ref(false);
const chown = ref(false);
const preserveAttributes = ref(false);
watch(mountOptionsPrivateKey, () => {
if (!mountOptionsPrivateKey.value) return;
mountOptionsPrivateKey.value = mountOptionsPrivateKey.value.replaceAll('\\n', '\n');
});
async function onSubmit() {
busy.value = true;
@@ -249,13 +254,13 @@ defineExpose({
<FormGroup>
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
<div description class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
<input type="range" id="memoryLimitInput" v-model="memoryLimit" :step="256*1024*1024" :min="minMemoryLimit" :max="maxMemoryLimit" />
</FormGroup>
<FormGroup v-if="s3like(provider)">
<label for="uploadPartSizeInput">{{ $t('backups.configureBackupStorage.uploadPartSize') }}: <b>{{ prettyBinarySize(uploadPartSize, 'Default (50 MiB)') }}</b></label>
<p class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</p>
<div description class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</div>
<input type="range" id="uploadPartSizeInput" v-model="uploadPartSize" list="uploadPartSizeTicks" :step="1024*1024" :min="10*1024*1024" :max="1024*1024*1024" />
<datalist id="uploadPartSizeTicks">
<option :value="1024*1024*10"></option>
@@ -269,21 +274,19 @@ defineExpose({
<FormGroup v-if="site.format === 'rsync'">
<label for="syncConcurrencyInput">{{ $t('backups.configureBackupStorage.uploadConcurrency') }}: <b>{{ syncConcurrency }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
<div description class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
<input type="range" id="syncConcurrencyInput" v-model="syncConcurrency" step="10" min="10" max="200" />
</FormGroup>
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
<label for="downloadConcurrencyInput">{{ $t('backups.configureBackupStorage.downloadConcurrency') }}: <b>{{ downloadConcurrency }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
<div description class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
<input type="range" id="downloadConcurrencyInput" v-model="downloadConcurrency" step="10" min="10" max="200" />
</FormGroup>
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
<label for="copyConcurrencyInput">{{ $t('backups.configureBackupStorage.copyConcurrency') }}: <b>{{ copyConcurrency }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}
<span v-show="provider === 'digitalocean-spaces'">{{ $t('backups.configureBackupStorage.copyConcurrencyDigitalOceanNote') }}</span>
</div>
<div description class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}</div>
<input type="range" id="copyConcurrencyInput" v-model="copyConcurrency" step="10" min="10" max="500" />
</FormGroup>
</fieldset>
@@ -109,12 +109,14 @@ defineExpose({
>
<div>
<div>
<p>{{ $t('backups.configureBackupStorage.backupContents.context', { name: site.name }) }}</p>
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
<fieldset :disabled="busy">
<input style="display: none;" type="submit"/>
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<FormGroup>
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
@@ -1,7 +1,7 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { Checkbox, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
import { Radiobutton, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
import BackupSitesModel from '../models/BackupSitesModel.js';
import { cronDays, cronHours } from '../utils.js';
@@ -9,11 +9,11 @@ const emit = defineEmits([ 'success' ]);
const backupSitesModel = BackupSitesModel.create();
const id = ref('');
const site = ref({});
const busy = ref(false);
const formError = ref('');
const dialog = useTemplateRef('dialog');
const scheduleEnabled = ref(false);
const scheduleType = ref('');
const days = ref([]);
const hours = ref([]);
const configureRetention = ref(''); // this is 'name' and not 'id' of backupRetentions because SingleSelect needs strings
@@ -27,7 +27,7 @@ async function onSubmit() {
busy.value = true;
let schedule;
if (scheduleEnabled.value) {
if (scheduleType.value === 'pattern') {
let daysPattern;
if (days.value.length === 7) daysPattern = '*';
else daysPattern = days.value;
@@ -41,7 +41,7 @@ async function onSubmit() {
schedule = 'never';
}
let [error] = await backupSitesModel.setSchedule(id.value, schedule);
let [error] = await backupSitesModel.setSchedule(site.value.id, schedule);
if (error) {
busy.value = false;
formError.value = error.body ? error.body.message : 'Internal error';
@@ -49,7 +49,7 @@ async function onSubmit() {
}
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return x.name === configureRetention.value; });
[error] = await backupSitesModel.setRetention(id.value, selectedRetention.id);
[error] = await backupSitesModel.setRetention(site.value.id, selectedRetention.id);
if (error) {
busy.value = false;
formError.value = error.body ? error.body.message : 'Internal error';
@@ -63,21 +63,21 @@ async function onSubmit() {
}
defineExpose({
async open(site) {
id.value = site.id;
async open(s) {
site.value = s;
busy.value = false;
formError.value = false;
const currentRetentionString = JSON.stringify(site.retention);
const currentRetentionString = JSON.stringify(site.value.retention);
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return JSON.stringify(x.id) === currentRetentionString; });
configureRetention.value = selectedRetention ? selectedRetention.name : BackupSitesModel.backupRetentions[0].name;
if (site.schedule === 'never') {
scheduleEnabled.value = false;
if (site.value.schedule === 'never') {
scheduleType.value = 'never';
} else {
scheduleEnabled.value = true;
scheduleType.value = 'pattern';
const tmp = site.schedule.split(' ');
const tmp = site.value.schedule.split(' ');
const tmpHours = tmp[2].split(',');
const tmpDays = tmp[5].split(',');
@@ -105,18 +105,22 @@ defineExpose({
:confirm-active="isConfigureValid"
@confirm="onSubmit()"
>
<p>{{ $t('backups.configureBackupSchedule.schedule.context', { name: site.name }) }}</p>
<div class="error-label" v-show="formError">{{ formError }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset>
<FormGroup>
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule') }}</label>
<div v-html="$t('backups.configureBackupSchedule.scheduleDescription')"></div>
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule.title') }}</label>
<div description v-html="$t('backups.configureBackupSchedule.schedule.description')"></div>
<Checkbox :label="$t('main.statusEnabled')" v-model="scheduleEnabled" />
<div v-if="scheduleEnabled" style="display: flex; gap: 10px; margin-left: 10px">
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" :disabled="!scheduleEnabled" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect :disabled="!scheduleEnabled" v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
<Radiobutton v-model="scheduleType" value="never" :label="$t('backups.configureBackupSchedule.disable')"/>
<Radiobutton v-model="scheduleType" value="pattern" :label="$t('backups.configureBackupSchedule.enable')"/>
<div v-if="scheduleType === 'pattern'" style="display: flex; align-items: center; gap: 10px; margin: 10px">
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
<div class="text-small text-danger" v-show="scheduleType === 'pattern' && !(hours.length !== 0 && days.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
</div>
</FormGroup>
+1 -1
View File
@@ -77,7 +77,7 @@ onMounted(async () => {
<div style="display: flex; justify-content: space-around; margin-bottom: 20px;">
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
<label>{{ $t('branding.logo') }}</label>
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding"/>
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding" fallback-src="/api/v1/cloudron/avatar"/>
</div>
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
+1 -1
View File
@@ -130,7 +130,7 @@ defineExpose({ updateDomains: selectCurrentDomain });
<div class="error-label" v-if="formError">{{ formError }}</div>
<div v-if="lastTask.active" style="padding: 0 10px">
<div v-if="lastTask.active">
<ProgressBar :value="lastTask.percent" :busy="true" />
<div>{{ lastTask.message }}</div>
</div>
@@ -62,11 +62,12 @@ function onEditOrAdd(registry = null) {
async function onRemove(registry) {
const yes = await inputDialog.value.confirm({
title: t('dockerRegistries.removeDialog.title', { serverAddress: registry.serverAddress}),
message: t('dockerRegistres.removeDialog.description'),
title: t('dockerRegistries.removeDialog.title'),
message: t('dockerRegistres.removeDialog.description', { serverAddress: registry.serverAddress }),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.delete'),
rejectLabel: t('main.dialog.cancel')
confirmLabel: t('main.action.remove'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -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' },
];
@@ -83,8 +82,8 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="$t('dockerRegistries.dialog.title')"
:confirm-label="$t('main.dialog.save')"
:title="registry ? $t('dockerRegistries.dialog.editTitle') : $t('dockerRegistries.dialog.addTitle')"
:confirm-label="registry ? $t('main.dialog.save') : $t('main.action.add')"
:confirm-busy="busy"
:confirm-active="!busy && isFormValid"
:reject-label="$t('main.dialog.cancel')"
@@ -113,7 +112,7 @@ defineExpose({
</FormGroup>
<FormGroup>
<label for="emailInput">{{ $t('dockerRegistries.email') }} (Optional)</label>
<label for="emailInput">{{ $t('dockerRegistries.email') }} (optional)</label>
<TextInput id="emailInput" v-model="email" />
</FormGroup>
+5 -5
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, InputGroup, FormGroup, Checkbox, Button } from '@cloudron/pankow';
import { Dialog, TextInput, InputGroup, FormGroup, Button } from '@cloudron/pankow';
import { getTextFromFile } from '../utils.js';
import DomainsModel from '../models/DomainsModel.js';
import DomainProviderForm from './DomainProviderForm.vue';
@@ -131,10 +131,10 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="editing ? $t('domains.domainDialog.editTitle', { domain: domain }) : $t('domains.domainDialog.addTitle')"
:title="editing ? $t('domains.domainDialog.editTitle') : $t('domains.domainDialog.addTitle')"
:confirm-busy="busy"
:confirm-active="!busy && isFormValid"
:confirm-label="$t('main.dialog.save')"
:confirm-label="editing ? $t('main.dialog.save') : $t('main.action.add')"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
reject-style="secondary"
@@ -156,14 +156,14 @@ defineExpose({
<div v-show="showAdvanced">
<div v-if="tlsProvider === 'fallback'">
<label>{{ $t('domains.domainDialog.fallbackCertCustomCert') }}</label>
<p v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></p>
<div description v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></div>
</div>
<div v-if="tlsProvider === 'fallback'">
<input type="file" ref="certificateFileInput" style="display: none" @change="onCertificateFileChange"/>
<input type="file" ref="keyFileInput" style="display: none" @change="onKeyFileChange"/>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px">
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-top: 15px">
<label>{{ $t('domains.domainDialog.fallbackCertCertificatePlaceholder') }}</label>
<InputGroup>
<TextInput v-model="certificateFileName" @click="certificateFileInput.click()" style="cursor: pointer; flex-grow: 1;" :disabled="busy" />
@@ -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>
@@ -148,7 +152,7 @@ function onGcdnsFileInputChange(event) {
<input type="file" id="gcdnsKeyFileInput" style="display:none" accept="application/json, text/json" @change="onGcdnsFileInputChange"/>
<label class="control-label">{{ $t('domains.domainDialog.gcdnsServiceAccountKey') }}{{ dnsConfig.projectId ? ` - project: ${dnsConfig.projectId}` : '' }}</label>
<InputGroup>
<TextInput readonly required style="flex-grow: 1" v-model="dnsConfig.credentials.client_email" placeholder="Service Account Key" onclick="getElementById('gcdnsKeyFileInput').click();"/>
<TextInput readonly required style="flex-grow: 1" v-model="dnsConfig.credentials.client_email" placeholder="Service account key" onclick="getElementById('gcdnsKeyFileInput').click();"/>
<Button tool icon="fa fa-upload" onclick="document.getElementById('gcdnsKeyFileInput').click();"/>
</InputGroup>
<div class="error-label" v-show="gcdnsFileParseError">{{ gcdnsFileParseError }}</div>
@@ -316,13 +320,9 @@ function onGcdnsFileInputChange(event) {
<Checkbox v-if="showAdvanced" v-model="customNameservers" :label="$t('domains.domainDialog.customNameservers')" />
<FormGroup v-if="showAdvanced">
<label>Certificate Provider <sup><a href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label>Certificate provider <sup><a href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<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>
+8 -6
View File
@@ -113,15 +113,16 @@ onMounted(async () => {
@confirm="onBlocklistSubmit()"
>
<div>
<p class="small">{{ $t('network.firewall.configure.description') }}</p>
<div class="small">{{ $t('network.firewall.configure.description') }}</div>
<br/>
<form novalidate @submit.prevent="onBlocklistSubmit()" autocomplete="off">
<fieldset :disabled="editBlocklistBusy">
<input style="display: none" type="submit" :disabled="editBlocklistBusy || !isBlocklistValid"/>
<FormGroup>
<label for="blocklistInput">{{ $t('network.firewall.blockedIpRanges') }}</label>
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
<div class="has-error" v-show="editBlocklistError">{{ editBlocklistError }}</div>
<textarea id="blocklistInput" v-model="editBlocklist" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
<textarea id="blocklistInput" v-model="editBlocklist" rows="4"></textarea>
</FormGroup>
</fieldset>
</form>
@@ -138,15 +139,16 @@ onMounted(async () => {
@confirm="onTrustedIpsSubmit()"
>
<div>
<p class="small">{{ $t('network.trustedIps.description') }}</p>
<div class="small">{{ $t('network.trustedIps.description') }}</div>
<br/>
<form novalidate @submit.prevent="onTrustedIpsSubmit()" autocomplete="off">
<fieldset :disabled="editTrustedIpsBusy">
<input style="display: none;" type="submit" :disabled="editTrustedIpsBusy || !isTrustedIpsValid"/>
<FormGroup>
<label for="">{{ $t('network.trustedIpRanges') }}</label>
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
<div class="has-error" v-show="editTrustedIpsError">{{ editTrustedIpsError }}</div>
<textarea v-model="editTrustedIps" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
<textarea v-model="editTrustedIps" rows="4"></textarea>
</FormGroup>
</fieldset>
</form>
+11 -8
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,
}];
@@ -109,9 +109,10 @@ async function onNewFile() {
message: t('filemanager.newFileDialog.title'),
value: '',
required: true,
confirmStyle: 'success',
confirmStyle: 'primary',
confirmLabel: t('filemanager.newFileDialog.create'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!newFileName) return;
@@ -125,9 +126,10 @@ async function onNewFolder() {
message: t('filemanager.newDirectoryDialog.title'),
value: '',
required: true,
confirmStyle: 'success',
confirmStyle: 'primary',
confirmLabel: t('filemanager.newFileDialog.create'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!newFolderName) return;
@@ -239,8 +241,9 @@ async function deleteHandler(files) {
const confirmed = await inputDialog.value.confirm({
message: t('filemanager.removeDialog.reallyDelete'),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no'),
confirmLabel: t('main.dialog.delete'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!confirmed) return;
@@ -370,8 +373,8 @@ async function onRestartApp() {
const confirmed = await inputDialog.value.confirm({
message: t('filemanager.toolbar.restartApp') + '?',
confirmStyle: 'primary',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no'),
confirmLabel: t('app.repair.recovery.restartAction'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
@@ -443,7 +446,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 {
+2 -2
View File
@@ -79,7 +79,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="group ? $t('users.editGroupDialog.title', { name: group.name }) : $t('users.addGroupDialog.title')"
:title="group ? $t('users.editGroupDialog.title') : $t('users.addGroupDialog.title')"
:confirm-label="group ? $t('main.dialog.save') : $t('users.group.addGroupAction')"
:confirm-busy="busy"
:confirm-active="!busy && name !== ''"
@@ -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>
+8
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) {
@@ -41,6 +44,9 @@ async function onMarkNotificationRead(notification) {
if (error) return console.error(error);
await refresh();
// close after 2 seconds if there is nothing to show
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
}
async function onMarkAllNotificationRead() {
@@ -55,6 +61,8 @@ async function onMarkAllNotificationRead() {
await refresh();
notificationsAllBusy.value = false;
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
}
async function refresh() {
+9 -14
View File
@@ -10,8 +10,8 @@ const props = defineProps({
mode: { type: String, default: 'editable', required: true },
src: { type: String, required: true },
fallbackSrc: { type: String, required: true },
size: { type: String, required: true },
maxSize: { type: String, required: false },
size: { type: Number, required: false, default: 512 },
maxSize: { type: Number, required: false, default: 0 },
displayHeight: { type: String, required: false },
displayWidth: { type: String, required: false },
disabled: { type: Boolean, required: false },
@@ -109,22 +109,19 @@ function onChanged(event) {
fr.onload = function () {
const image = new Image();
image.onload = function () {
const size = props.size ? parseInt(props.size) : 512;
const maxSize = props.maxSize ? parseInt(props.maxSize) : 0;
const canvas = document.createElement('canvas');
if (maxSize) {
if (image.naturalWidth > maxSize) {
canvas.width = maxSize;
canvas.height = (image.naturalHeight / image.naturalWidth) * maxSize;
if (props.maxSize) {
if (image.naturalWidth > props.maxSize) {
canvas.width = props.maxSize;
canvas.height = (image.naturalHeight / image.naturalWidth) * props.maxSize;
} else {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
}
} else {
canvas.width = size;
canvas.height = size;
canvas.width = props.size;
canvas.height = props.size;
}
const imageDimensionRatio = image.width / image.height;
@@ -155,8 +152,7 @@ function onChanged(event) {
internalSrc.value = canvas.toDataURL('image/png');
isChanged.value = true;
console.log('internalSrc is now some data url');
emit('changed', file);
emit('changed', dataURLtoFile(internalSrc.value, 'image.png'));
};
image.src = fr.result;
@@ -177,7 +173,6 @@ function onError() {
<div ref="image" :disabled="disabled || null" class="image-picker" @click="!disabled && onEdit()">
<img :src="internalSrc || src" @error="onError" class="image-picker-image" :style="{ height: displayHeight || null, width: displayWidth || null }">
<!-- Editable mode -->
<template v-if="mode === 'editable'">
<div v-if="isChanged" class="image-picker-actions" style="visibility: visible;">
@@ -56,13 +56,14 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="$t('users.setGhostDialog.title', { username: user.username })"
:title="$t('users.setGhostDialog.title')"
:reject-label="success ? $t('main.dialog.close') : $t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="success ? '' : $t('users.setGhostDialog.setPassword')"
:confirm-busy="busy"
@confirm="onSubmit()"
>
<p>{{ $t('users.setGhostDialog.context', { username: user.username }) }}</p>
<p>{{ $t('users.setGhostDialog.description') }}</p>
<p class="text-danger" v-show="formError">{{ formError }}</p>
<form @submit.prevent="onSubmit()" autocomplete="off">
@@ -59,7 +59,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="$t('users.invitationDialog.title', { username: user? (user.username || user.email) : '' })"
:title="$t('users.invitationDialog.title')"
:reject-label="$t('main.dialog.close')"
reject-style="secondary"
>
@@ -68,6 +68,8 @@ defineExpose({
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
</div>
<div v-else>
<p>{{ $t('users.invitationDialog.context', { username: user? (user.username || user.email) : '' }) }}</p>
<FormGroup>
<label>{{ $t('users.invitationDialog.descriptionLink') }}</label>
<InputGroup>
+11 -11
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
import Section from '../components/Section.vue';
import NetworkModel from '../models/NetworkModel.js';
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
const providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
{ name: 'Static IP address', value: 'fixed' },
{ name: 'Network interface', value: 'network-interface' }
];
function prettyIpProviderName(provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
case 'fixed': return 'Static IP address';
case 'network-interface': return 'Network interface';
default: return 'Unknown';
}
}
@@ -113,26 +113,26 @@ onMounted(async () => {
<FormGroup>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
<p class="has-error" v-show="editError.generic">{{ editError.generic }}</p>
<div class="has-error" v-show="editError.generic">{{ editError.generic }}</div>
</FormGroup>
<p v-show="editProvider === 'generic'">
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</p>
</div>
<!-- Fixed -->
<FormGroup v-show="editProvider === 'fixed'">
<label for="addressInput">{{ $t('network.ipv4.address') }}</label>
<TextInput id="addressInput" v-model="editAddress" :required="editProvider === 'fixed'" />
<p class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</p>
<div class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</div>
</FormGroup>
<!-- Network Interface -->
<FormGroup v-show="editProvider === 'network-interface'">
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet -br addr</code></p>
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet -br addr <ClipboardAction plain value="ip -f inet -br addr" /></div>
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
<p class="has-error" v-show="editError.ifname">{{ editError.ifname }}</p>
<div class="has-error" v-show="editError.ifname">{{ editError.ifname }}</div>
</FormGroup>
</fieldset>
</form>
+7 -7
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
import Section from '../components/Section.vue';
import NetworkModel from '../models/NetworkModel.js';
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
const providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
{ name: 'Static IP address', value: 'fixed' },
{ name: 'Network interface', value: 'network-interface' }
];
function prettyIpProviderName(provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
case 'fixed': return 'Static IP address';
case 'network-interface': return 'Network interface';
default: return 'Unknown';
}
}
@@ -116,7 +116,7 @@ onMounted(async () => {
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
</FormGroup>
<div v-show="editProvider === 'generic'">
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</div>
@@ -130,9 +130,9 @@ onMounted(async () => {
<!-- Network Interface -->
<FormGroup v-show="editProvider === 'network-interface'">
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet6 -br addr <ClipboardAction plain value="ip -f inet6 -br addr" /></div>
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
<div class="error-label" v-show="editError.ifname">{{ editError.ifname }}</div>
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet6 -br addr</code></p>
</FormGroup>
</fieldset>
</form>
+3 -3
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);
@@ -72,7 +72,6 @@ onMounted(async () => {
<template>
<Section :title="$t('users.exposedLdap.title')">
<div>{{ $t('users.exposedLdap.description') }}</div>
<br/>
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="busy">
@@ -99,7 +98,8 @@ onMounted(async () => {
<FormGroup>
<label for="allowlistInput">{{ $t('users.exposedLdap.ipRestriction.label') }}</label>
<div description v-html="$t('users.exposedLdap.ipRestriction.description')"></div>
<textarea id="allowlistInput" v-model="allowlist" :placeholder="$t('users.exposedLdap.ipRestriction.placeholder')" rows="4" required></textarea>
<textarea id="allowlistInput" v-model="allowlist" rows="4" required></textarea>
<small style="helper-text">{{ $t('users.exposedLdap.ipRestriction.placeholder') }}</small>
<div class="has-error" v-show="editError.allowlist">{{ editError.allowlist }}</div>
</FormGroup>
</fieldset>
@@ -7,7 +7,10 @@ import MailModel from '../models/MailModel.js';
import { RELAY_PROVIDERS } from '../constants.js';
import { prettyRelayProviderName } from '../utils';
const props = defineProps(['domain']);
const props = defineProps({
domain: { type: String, required: true },
adminDomain: { type: String, required: true }
});
const mailModel = MailModel.create();
@@ -20,7 +23,7 @@ const mailConfig = ref({});
const dialog = useTemplateRef('dialog');
const busy = ref(false);
const formError = ref('');
const adminDomain = ref('');
const currentProvider = ref('cloudron-smtp');
const provider = ref('cloudron-smtp');
const host = ref('');
const port = ref(1);
@@ -130,6 +133,8 @@ async function onSubmit() {
return console.error(error);
}
currentProvider.value = provider.value;
dialog.value.close();
busy.value = false;
@@ -140,6 +145,7 @@ onMounted(async () => {
if (error) return console.error(error);
provider.value = result.relay.provider;
currentProvider.value = result.relay.provider;
});
</script>
@@ -207,7 +213,7 @@ onMounted(async () => {
<FormGroup>
<label>{{ $t('email.outbound.title') }} <sup><a href="https://docs.cloudron.io/email/#relay-outbound-mails" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div>
<b>{{ prettyRelayProviderName(provider) }}</b> - <span v-html="$t('email.outbound.description')"></span>
<span>{{ prettyRelayProviderName(currentProvider) }}</span> / <span v-html="$t('email.outbound.description')"></span>
</div>
</FormGroup>
<div style="display: flex; align-items: center;">
@@ -109,7 +109,7 @@ onMounted(async () => {
</template>
<SettingsItem wrap>
<div style="display: flex; align-items: center">
<div style="display: flex; align-items: center; width: 100%">
<div v-html="$t('emails.changeDomainDialog.description')"></div>
</div>
<form @submit.prevent="onSubmit()" style="display: flex; align-items: center; width: 100%; justify-content: end;" autocomplete="off">
@@ -118,7 +118,7 @@ onMounted(async () => {
<InputGroup style="overflow: hidden;">
<TextInput v-model="subdomain" :disabled="busy" style="width: 120px"/>
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('main.dialog.save') }}</Button>
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('emails.changeDomainDialog.setAction') }}</Button>
</InputGroup>
</form>
</SettingsItem>
+9 -7
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;
@@ -138,7 +140,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="mailbox ? $t('email.editMailboxDialog.title', { name: mailbox.name, domain: mailbox.domain }) : $t('email.addMailboxDialog.title')"
:title="mailbox ? $t('email.editMailboxDialog.title') : $t('email.addMailboxDialog.title')"
:confirm-label="$t(mailbox ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
:confirm-busy="busy"
:confirm-active="!busy && name !== '' && domain !== ''"
@@ -153,11 +155,11 @@ defineExpose({
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
<FormGroup v-if="!mailbox">
<FormGroup>
<label for="nameInput">{{ $t('email.addMailboxDialog.name') }}</label>
<InputGroup>
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailbox ? true : undefined"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox"/>
</InputGroup>
<div class="warning-label" v-if="!domainHasIncomingEnabled">{{ $t('email.addMailboxDialog.incomingDisabledWarning') }}</div>
<div class="error-label" v-if="formError">{{ formError }}</div>
@@ -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;
@@ -83,7 +84,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="mailinglist ? $t('email.editMailinglistDialog.title', { name: mailinglist.name, domain: mailinglist.domain }) : $t('email.addMailinglistDialog.title')"
:title="mailinglist ? $t('email.editMailinglistDialog.title') : $t('email.addMailinglistDialog.title')"
:confirm-label="$t(mailinglist ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
:confirm-busy="busy"
:confirm-active="!busy && name !== '' && domain !== '' && membersText !== ''"
@@ -99,11 +100,11 @@ defineExpose({
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
<FormGroup v-if="!mailinglist">
<FormGroup>
<label for="nameInput">{{ $t('email.addMailinglistDialog.name') }}</label>
<InputGroup>
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailinglist ? true : undefined"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailinglist"/>
</InputGroup>
<div class="error-label" v-if="formError.name">{{ formError.name }}</div>
</FormGroup>
@@ -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>
+8 -1
View File
@@ -14,7 +14,7 @@ const udpPorts = defineModel('udp');
<div v-for="ports in [ tcpPorts, udpPorts ]" :key="ports">
<FormGroup v-for="(port, key) in ports" :key="key" style="margin-top: 10px;">
<Checkbox :label="port.title" v-model="port.enabled" />
<small>{{ port.description + '. ' + (port.portCount >=1 ? (port.portCount + ' ports. ') : '') }}</small>
<small>{{ port.description + (port.portCount > 1 ? ('. ' + port.portCount + ' ports. ') : '') }}</small>
<small v-show="port.readOnly">{{ $t('appstore.installDialog.portReadOnly') }}</small>
<small class="has-error" v-if="error.port === port.value">Port already taken {{ port }}</small>
<NumberInput v-model="port.value" :disabled="!port.enabled" :min="1"/>
@@ -24,3 +24,10 @@ const udpPorts = defineModel('udp');
</FormGroup>
</div>
</template>
<style scoped>
.pankow-form-group small {
display: block;
margin-bottom: 0.5rem;
}
</style>
+1 -1
View File
@@ -59,7 +59,7 @@ defineExpose({
<div class="info-row">
<div class="info-label">{{ $t('app.accessControl.sftp.port') }}</div>
<div class="info-value">222 <ClipboardAction plain :value="222" /></div>
<div class="info-value">222 <ClipboardAction plain value="222" /></div>
</div>
<div class="info-row">
+42 -8
View File
@@ -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
@@ -316,16 +340,26 @@ defineExpose({ refresh });
<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/>
<div style="margin-bottom: 5px">{{ $t('backups.backupDetails.list', { appCount: infoBackup.appCount }) }}:</div>
<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>
<!-- {{ prettyDuration(content.stats.upload.duration | content.stats.copy.duration) }} -->
<span v-if="content.stats?.upload">&nbsp;{{ prettyFileSize(content.stats.upload.size) }} | {{ content.stats.upload.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"
+1
View File
@@ -32,6 +32,7 @@ async function onReboot() {
confirmLabel: t('main.rebootDialog.rebootAction'),
confirmStyle: 'danger',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!confirmed) return;
+35 -32
View File
@@ -6,7 +6,7 @@ const t = i18n.t;
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { marked } from 'marked';
import { Button, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox, InputDialog } from '@cloudron/pankow';
import { Button, FormGroup, Dialog, ProgressBar, Radiobutton, MultiSelect, Checkbox, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import { TASK_TYPES, ISTATES } from '../constants.js';
import Section from '../components/Section.vue';
@@ -185,6 +185,7 @@ async function refreshTasks() {
if (error) return console.error(error);
lastTask.value = result[0] || {};
if (result.length && !result[0].active && !result[0].success) updateError.value.generic = result[0].error.message;
taskLogsMenu.value = result.map(t => {
return {
@@ -276,39 +277,38 @@ onMounted(async () => {
<InputDialog ref="inputDialog"/>
<Dialog ref="updateDialog"
:title="$t('settings.updateDialog.title') + ` v${pendingUpdate ? pendingUpdate.version : ''}`"
:title="$t('settings.updateDialog.title')"
:confirm-label="$t('settings.updateDialog.updateAction')"
:confirm-active="canUpdate"
:confirm-busy="updateBusy"
:confirm-style="pendingUpdate && pendingUpdate.unstable ? 'danger' : 'primary'"
:confirm-style="pendingUpdate?.unstable ? 'danger' : 'primary'"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!updateBusy"
reject-style="secondary"
@confirm="onSubmitUpdate()"
>
<div v-if="pendingUpdate">
<div v-if="canUpdate">
<p v-if="pendingUpdate.unstable" class="error-label">{{ $t('settings.updateDialog.unstableWarning') }}</p>
<div v-if="pendingUpdate && canUpdate">
<h3>{{ $t('settings.updateDialog.updateAvailable', { newVersion: `v${pendingUpdate.version}` }) }}</h3>
<p v-if="pendingUpdate.unstable" class="error-label">{{ $t('settings.updateDialog.unstableWarning') }}</p>
<div>{{ $t('settings.updateDialog.changes') }}:</div>
<div class="changelog-container">
<ul class="changelogs">
<li v-for="change in pendingUpdate.changelog" :key="change" v-html="marked.parse(change)"></li>
</ul>
</div>
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('settings.updateDialog.skipBackupCheckbox')"/>
</div>
<div v-else>
<p>{{ $t('settings.updateDialog.blockingApps') }}</p>
<ul>
<li v-for="app in inProgressApps" :key="app.id">{{ app.fqdn }}</li>
<div>{{ $t('settings.updateDialog.changes') }}:</div>
<div class="changelog-container">
<ul class="changelogs">
<li v-for="change in pendingUpdate.changelog" :key="change" v-html="marked.parse(change)"></li>
</ul>
<span>{{ $t('settings.updateDialog.blockingAppsInfo') }}</span>
<br/>
<br/>
</div>
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('settings.updateDialog.skipBackupCheckbox')"/>
</div>
<!-- !canUpdate -->
<div v-else>
<p>{{ $t('settings.updateDialog.blockingApps') }}</p>
<ul>
<li v-for="app in inProgressApps" :key="app.id">{{ app.fqdn }}</li>
</ul>
<span>{{ $t('settings.updateDialog.blockingAppsInfo') }}</span>
<br/>
<br/>
</div>
</Dialog>
@@ -320,18 +320,20 @@ onMounted(async () => {
reject-style="secondary"
@confirm="onSubmitConfigure()"
>
<p v-html="$t('settings.updateScheduleDialog.description')"></p>
<FormGroup>
<div description v-html="$t('settings.updateScheduleDialog.description')"></div>
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')" style="margin-top: 10px"/>
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')"/>
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin-top: 10px">
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
</div>
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin: 10px 0px 0px 25px">
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
</div>
</FormGroup>
</Dialog>
<Section :title="$t('settings.updates.title')">
@@ -365,6 +367,7 @@ onMounted(async () => {
<div class="error-label" v-if="stopError.generic">{{ stopError.generic }}</div>
<div class="error-label" v-if="updateCheckError.generic">{{ updateCheckError.generic }}</div>
<div class="error-label" v-if="updateError.generic">{{ updateError.generic }}</div>
<div class="button-bar" v-if="ready">
<Button :disabled="checkingBusy" :loading="checkingBusy" v-if="!updateBusy" @click="onCheck()">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
+4 -2
View File
@@ -46,11 +46,13 @@ async function onDownload() {
downloadFileDownloadUrl.value = '';
const downloadFileName = await inputDialog.value.prompt({
message: t('terminal.downloadAction'),
title: t('terminal.download.title'),
message: t('terminal.download.description'),
value: '',
confirmStyle: 'success',
confirmStyle: 'primary',
confirmLabel: t('terminal.download.download'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!downloadFileName) return;
+14 -13
View File
@@ -228,7 +228,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="user ? $t('users.editUserDialog.title', { username: (user.username || user.email) }) : $t('users.addUserDialog.title')"
:title="user ? $t('users.editUserDialog.title') : $t('users.addUserDialog.title')"
:confirm-label="user ? $t('main.dialog.save') : $t('users.addUserDialog.addUserAction')"
:confirm-busy="busy"
:confirm-active="!busy"
@@ -255,23 +255,23 @@ defineExpose({
</div>
</div>
<FormGroup>
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<TextInput id="emailInput" v-model="email" :disabled="(user && user.source) ? true : null" required />
<div class="text-danger" v-if="formError.email">{{ formError.email }}</div>
<!-- if profile edit is locked a user has to be set here . username is editable until one is set -->
<FormGroup :has-error="formError.username">
<label for="usernameInput">{{ $t('users.user.username') }}</label>
<TextInput id="usernameInput" v-model="username" :required="profileLocked ? true : null" :readonly="user?.username ? true : undefined" />
<small v-if="!user?.username && !profileLocked" class="helper-text">{{ $t('users.user.usernamePlaceholder') }}</small>
<div class="text-danger" v-if="formError.username">{{ formError.username }}</div>
</FormGroup>
<!-- if profile edit is locked a user has to be set here . username is editable until one is set -->
<FormGroup v-if="!user || !user.username" :has-error="formError.username">
<label for="usernameInput">{{ $t('users.user.username') }}</label>
<TextInput id="usernameInput" v-model="username" :required="profileLocked ? true : null" />
<small class="helper-text">{{ profileLocked ? '' : $t('users.user.usernamePlaceholder') }}</small>
<div class="text-danger" v-if="formError.username">{{ formError.username }}</div>
<FormGroup>
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<TextInput id="emailInput" v-model="email" :readonly="(user && user.source) ? true : undefined" required />
<div class="text-danger" v-if="formError.email">{{ formError.email }}</div>
</FormGroup>
<FormGroup style="flex-grow: 1">
<label for="displayNameInput">{{ $t('users.user.fullName') }}</label>
<TextInput id="displayNameInput" v-model="displayName" :disabled="(user && user.source) ? true : null"/>
<TextInput id="displayNameInput" v-model="displayName" :readonly="(user && user.source) ? true : undefined"/>
<small v-if="!user || !user.username" class="helper-text">{{ $t('users.user.displayNamePlaceholder') }}</small> <!-- don't show if user has already signed up -->
</FormGroup>
@@ -294,7 +294,8 @@ defineExpose({
<MultiSelect v-if="allLocalGroups.length" v-model="localGroups" option-key="id" :options="allLocalGroups" :search-threshold="20" />
</FormGroup>
<Checkbox v-model="active" :disabled="isSelf" :label="$t('users.user.activeCheckbox')" help-url="https://docs.cloudron.io/user-management/#disable-user"/>
<!-- on add, this is hidden for now, until we figure why one would want to add an inactive user -->
<Checkbox v-if="user" v-model="active" :disabled="isSelf" :label="$t('users.user.activeCheckbox')" help-url="https://docs.cloudron.io/user-management/#disable-user"/>
<Checkbox v-if="!user" v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
</fieldset>
</form>
+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" :sso="app.sso" :installation="false"/>
<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>
+5 -6
View File
@@ -70,7 +70,7 @@ function onActionMenu(backup, event) {
icon: 'fa-solid fa-download',
label: t('app.backups.backups.downloadBackupTooltip'),
visible: backup.site.format === 'tgz' && props.app.accessLevel === 'admin',
action: getDownloadLink.bind(null, backup),
href: getDownloadLink(backup),
}, {
icon: 'fa-solid fa-file-alt',
label: t('app.backups.backups.downloadConfigTooltip'),
@@ -352,8 +352,8 @@ onMounted(async () => {
</Dialog>
<Dialog ref="restoreDialog"
:title="$t('app.restoreDialog.title', { app: app.fqdn })"
:reject-label="$t('main.dialog.close')"
:title="$t('app.restoreDialog.title')"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="$t('app.restoreDialog.restoreAction')"
:confirm-active="true"
@@ -361,16 +361,15 @@ onMounted(async () => {
@confirm="onRestoreSubmit()"
>
<div>
<p>{{ $t('app.restoreDialog.description', { creationTime: prettyLongDate(restoreBackup.creationTime) }) }}</p>
<p class="text-danger">{{ $t('app.restoreDialog.warning') }}</p>
<br/>
<p>{{ $t('app.restoreDialog.description', { fqdn: app.fqdn, creationTime: prettyLongDate(restoreBackup.creationTime) }) }}</p>
</div>
</Dialog>
<SettingsItem>
<FormGroup>
<label>{{ $t('app.backups.auto.title') }}</label>
<div v-html="$t('app.backups.auto.description', { backupLink: '/#/backups' })"></div>
<div v-html="$t('app.backups.auto.description')"></div>
</FormGroup>
<Switch v-model="autoBackupsEnabled" @change="onChangeAutoBackups"/>
</SettingsItem>
+3 -2
View File
@@ -89,7 +89,7 @@ onMounted(() => {
<div>
<div style="display: inline-block;">
<label>{{ $t('app.display.icon') }}</label>
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" fallback-src="/img/appicon_fallback.png" @changed="onIconChanged" size="512" display-height="96px"/>
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" fallback-src="/img/appicon_fallback.png" @changed="onIconChanged" :size="512" display-height="96px"/>
</div>
<form @submit.prevent="onSubmit()" autocomplete="off">
@@ -104,7 +104,8 @@ onMounted(() => {
<FormGroup>
<label for="tagsInput">{{ $t('app.display.tags') }}</label>
<TagInput id="tagsInput" :placeholder="$t('app.display.tagsPlaceholder')" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')"/>
<TagInput id="tagsInput" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')"/>
<small class="helper-text">{{ $t('app.display.tagsPlaceholder') }}</small>
<div class="text-error" v-if="tagsError">{{ tagsError }}</div>
</FormGroup>
</fieldset>
+4 -4
View File
@@ -128,13 +128,12 @@ onMounted(async () => {
<div>
<div v-if="hasSendmail">
<FormGroup>
<label>{{ $t('app.email.from.title') }} <sup><a href="https://docs.cloudron.io/apps/#mail-from-address" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label>{{ $t('app.email.configuration.title') }} <sup><a href="https://docs.cloudron.io/apps/#mail-from-address" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<Radiobutton v-if="sendmailOptional" v-model="enableMailbox" :value="1" :label="$t('app.email.from.enable')"/>
<div style="margin-bottom: 18px;" :style="{ 'padding-left': sendmailOptional ? '25px' : '0' }">
<div v-html="$t('app.email.from.enableDescription', { domain: app.domain, domainConfigLink: ('/#/email/' + app.domain) })"></div>
<br/>
<div v-html="$t('app.email.from.enableDescription', { domain: app.domain, domainConfigLink: ('/#/email-domain/' + app.domain) })"></div>
<form @submit.prevent="onSendmailSubmit()" autocomplete="off">
<fieldset :disabled="enableMailbox === 0 || sendmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">
@@ -143,6 +142,7 @@ onMounted(async () => {
<FormGroup>
<div class="has-error" v-if="sendmailError">{{ sendmailError }}</div>
<label>{{ $t('app.email.from.title') }}</label>
<div style="display: flex; gap: 10px;">
<TextInput v-if="sendmailSupportsDisplayName" v-model="sendmailDisplayName" :placeholder="$t('app.email.from.displayName')"/>
<InputGroup>
@@ -159,7 +159,7 @@ onMounted(async () => {
<div v-if="sendmailOptional" style="padding-left: 25px;">{{ $t('app.email.from.disableDescription') }}</div>
</FormGroup>
<br/>
<br v-if="sendmailOptional"/>
<Button @click="onSendmailSubmit()" :loading="sendmailBusy" :disabled="sendmailBusy || (app.error && app.error.installationState !== ISTATES.PENDING_SERVICES_CHANGE) || app.taskId">{{ $t('app.email.from.saveAction') }}</Button>
</div>
+4 -4
View File
@@ -53,9 +53,9 @@ onMounted(async () => {
<table class="eventlog-table pankow-no-mobile">
<thead>
<tr>
<th>{{ $t('eventlog.time') }}</th>
<th>{{ $t('eventlog.source') }}</th>
<th>{{ $t('eventlog.details') }}</th>
<th style="width: 160px">{{ $t('eventlog.time') }}</th>
<th style="width: 15%">{{ $t('eventlog.source') }}</th>
<th style="word-break: break-all; overflow-wrap: anywhere;">{{ $t('eventlog.details') }}</th>
</tr>
</thead>
<tbody>
@@ -66,7 +66,7 @@ onMounted(async () => {
<td v-html="eventlog.details"></td>
</tr>
<tr v-show="eventlog.isOpen">
<td colspan="4" class="eventlog-details">
<td colspan="3" class="eventlog-details">
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
</td>
+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>
+33 -14
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;
@@ -217,10 +226,9 @@ onMounted(async () => {
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="errorObject" :domain-provider="domainProvider"/>
<div v-if="app.manifest.multiDomain" style="margin-top: 20px">
<FormGroup v-if="app.manifest.multiDomain">
<label>{{ $t('app.location.aliases') }} <sup><a href="https://docs.cloudron.io/apps/#aliases" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="aliases.length === 0">{{ $t('app.location.noAliases') }}</div>
<div v-for="(item, index) in aliases" :key="item" style="margin-bottom: 10px">
<div style="display: flex; gap: 10px;">
<InputGroup style="flex-grow: 1">
@@ -232,13 +240,14 @@ onMounted(async () => {
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
</div>
<div class="actionable" v-if="!busy" @click="onAddAlias()">{{ $t('app.location.addAliasAction') }}</div>
</div>
<div>
<span v-if="aliases.length === 0">{{ $t('app.location.noAliases') }}.&nbsp;</span>
<span class="actionable" v-if="!busy" @click="onAddAlias()">{{ $t('app.location.addAliasAction') }}</span>
</div>
</FormGroup>
<div style="margin-top: 20px">
<FormGroup>
<label>{{ $t('app.location.redirections') }} <sup><a href="https://docs.cloudron.io/apps/#redirections" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="redirects.length === 0">{{ $t('app.location.noRedirections') }}</div>
<div v-for="(item, index) in redirects" :key="item" style="margin-bottom: 10px;">
<div style="display: flex; gap: 10px;">
<InputGroup style="flex-grow: 1">
@@ -250,8 +259,11 @@ onMounted(async () => {
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
</div>
<div class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</div>
</div>
<div>
<span v-if="redirects.length === 0">{{ $t('app.location.noRedirections') }}.&nbsp;</span>
<span class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</span>
</div>
</FormGroup>
</fieldset>
</form>
@@ -261,8 +273,15 @@ onMounted(async () => {
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
<Checkbox v-if="needsOverwriteDns" v-model="overwriteDns" :label="$t('app.location.dnsoverwrite')"/>
<br v-if="needsOverwriteDns"/>
<br/>
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid">{{ $t('app.location.saveAction') }}</Button>
</div>
</template>
<style scoped>
.pankow-form-group small {
display: block;
margin-bottom: 0.5rem;
}
</style>
+3 -2
View File
@@ -115,7 +115,7 @@ onMounted(async () => {
<div>
<FormGroup>
<label for="memoryLimitInput">{{ $t('app.resources.memory.title') }} <sup><a href="https://docs.cloudron.io/apps/#memory-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ prettyBinarySize(memoryLimit, 'Default (256 MiB)') }}</b></label>
<p>{{ $t('app.resources.memory.description') }}</p>
<div description>{{ $t('app.resources.memory.description') }}</div>
<input type="range" id="memoryLimitInput" v-model="memoryLimit" step="134217728" :min="memoryTicks[0]" :max="memoryTicks[memoryTicks.length-1]" list="memoryLimitTicks" />
<datalist id="memoryLimitTicks">
<option v-for="value of memoryTicks" :key="value" :value="value"></option>
@@ -128,7 +128,7 @@ onMounted(async () => {
<FormGroup>
<label for="cpuQuotaInput">{{ $t('app.resources.cpu.title') }} <sup><a href="https://docs.cloudron.io/apps/#cpu-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ cpuQuota + ' %' }}</b></label>
<p>{{ $t('app.resources.cpu.description') }}</p>
<div description>{{ $t('app.resources.cpu.description') }}</div>
<input type="range" id="cpuQuotaInput" v-model="cpuQuota" step="1" min="1" max="100" list="cpuQuotaTicks" />
<datalist id="cpuQuotaTicks">
<option value="25"></option>
@@ -146,6 +146,7 @@ onMounted(async () => {
<input style="display: none;" type="submit"/>
<FormGroup>
<label for="devicesInput">{{ $t('app.resources.devices.label') }} <sup><a href="https://docs.cloudron.io/apps/#devices" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div description>{{ $t('app.resources.devices.description') }}</div>
<TagInput id="devicesInput" v-model="devices" placeholder="/dev/ttyUSB0, /dev/hidraw0, ..."/>
<div class="text-danger" v-if="devicesError">{{ devicesError }}</div>
</FormGroup>
+4 -3
View File
@@ -51,7 +51,8 @@ onMounted(() => {
<span>{{ $t('app.security.robots.title') }} <sup><a href="https://docs.cloudron.io/apps/#robotstxt" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></span>
<Button small outline @click="onAddDisableIndexing()">{{ $t('app.security.robots.disableIndexingAction') }}</Button>
</label>
<textarea id="robotsTxtInput" style="white-space: pre-wrap; font-family: monospace;" v-model="robotsTxt" rows="10" :placeholder="$t('app.security.robots.txtPlaceholder')"></textarea>
<div description>{{ $t('app.security.robots.description') }}</div>
<textarea id="robotsTxtInput" style="white-space: pre-wrap; font-family: monospace;" v-model="robotsTxt" rows="10"></textarea>
</FormGroup>
<FormGroup>
@@ -60,9 +61,9 @@ onMounted(() => {
<textarea id="cspInput" style="white-space: pre-wrap; font-family: monospace;" v-model="csp" placeholder="default-src 'self'; frame-ancestors 'none';" rows="2"></textarea>
</FormGroup>
<div>
<FormGroup>
<Checkbox v-model="hstsPreload" style="display: inline-flex;" :label="$t('app.security.hstsPreload')" help-url="https://docs.cloudron.io/apps/#hsts-preload"/>
</div>
</FormGroup>
<br/>
<Button @click="onSubmit()" :loading="busy" :disabled="busy">{{ $t('app.security.csp.saveAction') }}</Button>
+3 -4
View File
@@ -192,8 +192,7 @@ onMounted(async () => {
<FormGroup>
<label>{{ $t('app.storage.mounts.title') }} <sup><a href="https://docs.cloudron.io/apps/#mounts" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="has-error" v-if="mountsError">{{ mountsError }}</div>
<div v-html="$t('storage.mounts.description')"></div>
<br/>
<div description v-html="$t('storage.mounts.description')"></div>
<table class="table table-hover" style="margin-top: 10px;" v-if="mounts.length">
<thead>
@@ -213,14 +212,14 @@ onMounted(async () => {
</td>
<td style="vertical-align: middle; text-align: right;">
<Button tool small secondary v-show="mount.volumeId" :href="`/filemanager.html#/home/volume/${mount.volumeId}`" target="_blank" v-tooltip="$t('volumes.openFileManagerActionTooltip')" icon="fa-solid fa-folder"/>
<Button tool small danger @click="onMountRemove(index)" icon="fa-solid fa-trash-alt" style="margin-left: 6px"/>
<Button danger tool @click="onMountRemove(index)" icon="fa-solid fa-trash" style="margin-left: 6px"/>
</td>
</tr>
</tbody>
</table>
<div style="margin-top: 10px;">
<span v-if="mounts.length === 0">{{ $t('app.storage.mounts.noMounts') }}&nbsp;</span>
<span v-if="mounts.length === 0">{{ $t('app.storage.mounts.noMounts') }}.&nbsp;</span>
<span class="actionable" @click="onMountAdd()">{{ $t('app.storage.mounts.addMountAction') }}</span>
</div>
</FormGroup>
+4 -4
View File
@@ -7,7 +7,7 @@ const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import { APP_TYPES, ISTATES, RSTATES } from '../../constants.js';
import { APP_TYPES } from '../../constants.js';
import AppsModel from '../../models/AppsModel.js';
const appsModel = AppsModel.create();
@@ -20,7 +20,7 @@ const latestBackup = ref(null);
async function onUninstall() {
const yes = await inputDialog.value.confirm({
title: t('app.uninstallDialog.title', { app: (props.app.label || props.app.fqdn) }),
title: t('app.uninstallDialog.title'),
message: t('app.uninstallDialog.description', { app: (props.app.label || props.app.fqdn) }),
confirmStyle: 'danger',
confirmLabel: t('app.uninstallDialog.uninstallAction'),
@@ -40,8 +40,8 @@ async function onArchive() {
if (!latestBackup.value) return;
const yes = await inputDialog.value.confirm({
title: t('app.archiveDialog.title', { app: (props.app.label || props.app.fqdn) }),
message: t('app.archiveDialog.description', { date: prettyLongDate(latestBackup.value.creationTime) }),
title: t('app.archiveDialog.title'),
message: t('app.archiveDialog.description', { app: (props.app.label || props.app.fqdn), date: prettyLongDate(latestBackup.value.creationTime) }),
confirmStyle: 'danger',
confirmLabel: t('app.archive.action'),
rejectLabel: t('main.dialog.cancel')
+1 -1
View File
@@ -119,7 +119,7 @@ onMounted(async () => {
<div>
<label>{{ $t('app.updates.auto.title') }}</label>
<div v-if="!app.appStoreId">{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
<div v-else>{{ $t('app.updates.auto.description') }}</div>
<div v-else v-html="$t('app.updates.auto.description')"></div>
</div>
<Switch v-if="app.appStoreId" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
</SettingsItem>
+1 -1
View File
@@ -331,7 +331,7 @@ const RELAY_PROVIDERS = [
{ provider: 'postmark-smtp', name: 'Postmark', host: 'smtp.postmarkapp.com', port: 587, spfDoc: 'https://postmarkapp.com/support/article/1092-how-do-i-set-up-spf-for-postmark' },
{ provider: 'sendgrid-smtp', name: 'SendGrid', host: 'smtp.sendgrid.net', port: 587, username: 'apikey', spfDoc: 'https://sendgrid.com/docs/ui/account-and-settings/spf-records/' },
{ provider: 'sparkpost-smtp', name: 'SparkPost', host: 'smtp.sparkpostmail.com', port: 587, username: 'SMTP_Injection', spfDoc: 'https://www.sparkpost.com/resources/email-explained/spf-sender-policy-framework/' },
{ provider: 'noop', name: 'Disable' },
{ provider: 'noop', name: 'Disabled' },
];
// named exports
+6 -1
View File
@@ -18,7 +18,12 @@ const i18n = createI18n({
// vue-js translation treats @ as linked data, so we have to replace them into a literal variable for now
// https://vue-i18n.intlify.dev/guide/essentials/syntax.html#literal-interpolation
return message.replaceAll('{{', '{').replaceAll('}}', '}').replaceAll('@', "{'@'}");
try {
return message.replaceAll('{{', '{').replaceAll('}}', '}').replaceAll('@', "{'@'}");
} catch (e) {
console.log(`Error translating key: ${key} message: ${JSON.stringify(message)}`, e);
return '';
}
}
});
+4
View File
@@ -7,6 +7,10 @@
<link rel="icon" href="<%- iconUrl %>?<%- Date.now() %>" type="image/png">
<link rel="apple-touch-icon" href="<%- iconUrl %>?<%- Date.now() %>" type="image/png">
<meta property="og:image" content="<%- iconUrl %>?<%- Date.now() %>">
<% } else if (locals.dashboardFqdn) { -%>
<link rel="icon" href="https://<%- dashboardFqdn %>/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
<link rel="apple-touch-icon" href="https://<%- dashboardFqdn %>/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
<meta property="og:image" content="https://<%- dashboardFqdn %>/api/v1/cloudron/avatar?<%- Date.now() %>">
<% } else { -%>
<link rel="icon" href="/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar?<%- Date.now() %>" type="image/png">
+1 -1
View File
@@ -44,7 +44,7 @@ function installationStateLabel(app) {
}
case ISTATES.INSTALLED: {
if (app.debugMode) {
return 'Recovery Mode';
return 'Paused (recovery mode)';
} else if (app.runState === RSTATES.RUNNING) {
if (!app.health) return 'Starting...'; // no data yet
if (app.type === APP_TYPES.LINK) return '';
+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 [];
+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
@@ -328,7 +328,7 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
return 'Backup cleaner started';
case ACTION_BACKUP_CLEANUP_FINISH:
return data.errorMessage ? 'Backup cleaner errored: ' + data.errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backups';
return data.errorMessage ? 'Backup cleaner errored: ' + data.errorMessage : 'Backup cleaner removed ' + (data.removedBoxBackupPaths ? data.removedBoxBackupPaths.length : '0') + ' backup(s)';
case ACTION_BACKUP_SITE_ADD:
return `New backup site ${data.name} added with provider ${data.provider} and format ${data.format}`;
+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>
+4 -3
View File
@@ -78,11 +78,12 @@ async function refreshArchives() {
const inputDialog = useTemplateRef('inputDialog');
async function onRemove(archive) {
const yes = await inputDialog.value.confirm({
title: t('backups.deleteArchiveDialog.title', { appTitle: archive.appConfig?.manifest?.title, fqdn: archive.appConfig?.fqdn }),
message: t('backups.deleteArchiveDialog.description'),
title: t('backups.deleteArchiveDialog.title'),
message: t('backups.deleteArchiveDialog.description', { appTitle: archive.appConfig?.manifest?.title, appFqdn: archive.appConfig?.fqdn }),
confirmStyle: 'danger',
confirmLabel: t('backups.deleteArchive.deleteAction'),
rejectLabel: t('main.dialog.cancel')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
+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" />
+37 -14
View File
@@ -32,17 +32,17 @@ const viewType = ref((localStorage.appsView && (localStorage.appsView === VIEW_T
const tagFilter = ref('');
const tagFilterOptions = ref([{
id: '',
name: 'All Tags',
name: 'All tags',
}]);
const domainFilter = ref('');
const domainFilterOptions = ref([]);
const stateFilter = ref('');
const stateFilterOptions = [
{ id: '', label: 'All States' },
{ id: '', label: 'All states' },
{ id: 'running', label: 'Running' },
{ id: 'stopped', label: 'Stopped' },
{ id: 'update_available', label: 'Update Available' },
{ id: 'not_responding', label: 'Not Responding' },
{ id: 'update_available', label: 'Update available' },
{ id: 'not_responding', label: 'Not responding' },
];
const listColumns = {
icon: {
@@ -134,21 +134,40 @@ function onActionMenu(app, event) {
const filteredApps = computed(() => {
return apps.value.filter(a => {
return a.fqdn.includes(filter.value) || a.id.includes(filter.value);
if (a.type === APP_TYPES.LINK) {
return a.upstreamUri.includes(filter.value);
} else { // app or proxy
return a.fqdn.includes(filter.value)
|| a.secondaryDomains.some(sd => sd.fqdn.includes(filter.value))
|| a.redirectDomains.some(rd => rd.fqdn.includes(filter.value))
|| a.aliasDomains.some(ad => ad.fqdn.includes(filter.value))
|| a.id.includes(filter.value)
|| a.manifest.title.toLocaleLowerCase().includes(filter.value.toLocaleLowerCase());
}
}).filter(a => {
if (!domainFilter.value) return true;
return a.domain === domainFilter.value;
if (a.type === APP_TYPES.LINK) return false;
return a.domain === domainFilter.value
|| a.secondaryDomains.some(sd => sd.domain === domainFilter.value)
|| a.redirectDomains.some(rd => rd.domain === domainFilter.value)
|| a.aliasDomains.some(ad => ad.domain === domainFilter.value);
}).filter(a => {
if (!tagFilter.value) return true;
return a.tags.indexOf(tagFilter.value) !== -1;
}).filter(a => {
if (!stateFilter.value) return true;
if (a.type === APP_TYPES.LINK) return false;
if (stateFilter.value === 'running') return a.runState === RSTATES.RUNNING && a.health === HSTATES.HEALTHY && a.installationState === ISTATES.INSTALLED;
if (stateFilter.value === 'stopped') return a.runState === RSTATES.STOPPED;
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);
});
});
@@ -220,7 +239,7 @@ async function refreshApps() {
// gets all tags used by all apps, flattens the arrays and new Set() will dedupe
const tags = [...new Set(apps.value.map(a => a.tags).flat())].map(t => { return { id: t, name: t }; });
tagFilterOptions.value = [{ id: '', name: 'All Tags', }].concat(tags);
tagFilterOptions.value = [{ id: '', name: 'All tags', }].concat(tags);
}
function toggleView() {
@@ -251,7 +270,7 @@ onActivated(async () => {
const [error, result] = await domainsModel.list();
if (error) return console.error(error);
domainFilterOptions.value = [{ id: '', domain: 'All Domains', }].concat(result.map(d => { d.id = d.domain; return d; }));
domainFilterOptions.value = [{ id: '', domain: 'All domains', }].concat(result.map(d => { d.id = d.domain; return d; }));
domainFilter.value = domainFilterOptions.value[0].id;
stateFilter.value = stateFilterOptions[0].id;
@@ -280,9 +299,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 +315,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 +336,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">
@@ -354,7 +373,7 @@ onDeactivated(() => {
<template #actions="app">
<div style="text-align: right;">
<!-- TODO v-tooltip="$t('app.configureTooltip')" but needs pankow fix -->
<Button class="action-button" tool plain secondary :href="`#/app/${app.id}/info`" icon="fa-solid fa-cog" />
<Button class="action-button" tool plain secondary @click="openAppEdit(app, $event)" :href="`#/app/${app.id}/info`" icon="fa-solid fa-cog" />
<Button tool plain secondary @click.capture="onActionMenu(app, $event)" icon="fa-solid fa-ellipsis" />
</div>
</template>
@@ -402,6 +421,10 @@ tr:hover .action-button {
transform: translateY(-30px);
}
.item-inactive {
filter: grayscale(1);
}
.list-icon {
width: 32px;
height: 32px;
+5 -3
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">
@@ -234,12 +236,12 @@ onDeactivated(() => {
</div>
<div v-else>
<div v-if="!search">
<h4 v-show="filteredPopularApps.length">{{ $t('appstore.category.popular') }}</h4>
<h2 v-show="filteredPopularApps.length">{{ $t('appstore.category.popular') }}</h2>
<div class="grid">
<AppStoreItem :style="{ width: itemWidth }" v-for="app in filteredPopularApps" :app="app" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)"/>
</div>
<h4 v-show="filteredAllApps.length">{{ $t('appstore.category.all') }}</h4>
<h2 v-show="filteredAllApps.length">{{ $t('appstore.category.all') }}</h2>
<div class="grid">
<AppStoreItem :style="{ width: itemWidth }" v-for="app in filteredAllApps" :app="app" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)"/>
</div>
+24 -16
View File
@@ -5,11 +5,11 @@ 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';
import BackupScheduleDialog from '../components/BackupScheduleDialog.vue';
import BackupSiteScheduleDialog from '../components/BackupSiteScheduleDialog.vue';
import BackupSiteAddDialog from '../components/BackupSiteAddDialog.vue';
import BackupSiteContentDialog from '../components/BackupSiteContentDialog.vue';
import BackupSiteConfigDialog from '../components/BackupSiteConfigDialog.vue';
@@ -40,9 +40,9 @@ function onEditContent(site) {
backupSiteContentDialog.value.open(site);
}
const backupScheduleDialog = useTemplateRef('backupScheduleDialog');
const backupSiteScheduleDialog = useTemplateRef('backupSiteScheduleDialog');
function onEditSchedule(site) {
backupScheduleDialog.value.open(site);
backupSiteScheduleDialog.value.open(site);
}
const backupSiteConfigDialog = useTemplateRef('backupSiteConfigDialog');
@@ -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) {
@@ -85,8 +90,8 @@ function prettyBackupContents(contents) {
async function onRemoveSite(site) {
const yes = await inputDialog.value.confirm({
title: t('backup.site.removeDialog.title'),
message: t('backup.site.removeDialog.description'),
confirmLabel: t('main.dialog.yes'),
message: t('backup.site.removeDialog.description', { name: site.name }),
confirmLabel: t('main.action.remove'),
confirmStyle: 'danger',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
@@ -112,6 +117,7 @@ async function onRemount(site) {
if (statusError) console.error(statusError);
site.status.state = status.state === 'active' ? 'success' : 'danger';
site.status.message = status.message;
site.status.busy = false;
}
@@ -155,6 +161,11 @@ const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(site, event) {
actionMenuModel.value = [{
icon: 'fa-solid fa-screwdriver-wrench',
label: t('backups.configAction'),
visible: profile.value.isAtLeastOwner,
action: onEditConfig.bind(null, site),
}, {
icon: 'fa-solid fa-box-open',
label: t('backups.contentAction'),
visible: profile.value.isAtLeastOwner,
@@ -164,12 +175,7 @@ function onActionMenu(site, event) {
label: t('backups.schedule.title'),
visible: profile.value.isAtLeastOwner,
action: onEditSchedule.bind(null, site),
}, {
icon: 'fa-solid fa-screwdriver-wrench',
label: t('backups.configAction'),
visible: profile.value.isAtLeastOwner,
action: onEditConfig.bind(null, site),
}, {
},{
visible: profile.value.isAtLeastOwner,
separator: true,
}, {
@@ -221,7 +227,8 @@ async function refreshStatusForSite(site) {
const [error, status] = await backupSitesModels.status(site.id);
if (error) return console.error(error);
site.status.state = status?.state === 'active' ? 'success' : 'danger';
site.status.state = status.state === 'active' ? 'success' : 'danger';
site.status.message = status.message;
site.status.busy = false;
}
@@ -279,7 +286,7 @@ onMounted(async () => {
<BackupSiteAddDialog ref="backupSiteAddDialog" @success="refresh()"/>
<BackupSiteContentDialog ref="backupSiteContentDialog" @success="refresh()"/>
<BackupSiteConfigDialog ref="backupSiteConfigDialog" @success="refresh()"/>
<BackupScheduleDialog ref="backupScheduleDialog" @success="refresh()"/>
<BackupSiteScheduleDialog ref="backupSiteScheduleDialog" @success="refresh()"/>
<Section :title="$t('backup.sites.title')">
<template #header-buttons>
@@ -337,6 +344,7 @@ onMounted(async () => {
<a :href="`/logs.html?taskId=${site.task.id}`" target="_blank"><span class="error-label">{{ site.task.error.message }} <Button small plain tool>Logs</Button></span></a>
</div>
</div>
<div style="margin-top: 10px;" class="text-danger" v-if="site.status.message" v-html="site.status.message"></div>
<div v-if="site.task && site.task.running">
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
<div style="flex-grow: 1; overflow: hidden;">
+1 -1
View File
@@ -60,7 +60,7 @@ async function onAskUnlinkAccount() {
title: t('settings.appstoreAccount.unlinkDialog.title'),
message: t('settings.appstoreAccount.unlinkDialog.description'),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
confirmLabel: t('settings.appstoreAccount.unlinkAction'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
+7 -5
View File
@@ -42,10 +42,12 @@ const inputDialog = useTemplateRef('inputDialog');
async function onRemove(domain) {
const yes = await inputDialog.value.confirm({
message: t('domains.removeDialog.title', { domain: domain.domain }),
title: t('domains.removeDialog.title'),
message: t('domains.removeDialog.description', { domain: domain.domain }),
confirmStyle: 'danger',
confirmLabel: t('domains.removeDialog.removeAction'),
rejectLabel: t('main.dialog.cancel')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -93,7 +95,7 @@ function onActionMenu(domain, event) {
}, {
icon: 'fa-solid fa-trash-alt',
label: t('main.action.remove'),
disabled: domain.domain.value === dashboardDomain.value,
disabled: domain.domain === dashboardDomain.value,
action: onRemove.bind(null, domain),
}];
@@ -148,7 +150,7 @@ onMounted(async () => {
<Button @click="onAdd()">{{ $t('main.action.add') }}</Button>
</template>
<div>{{ $t('domains.domainDialog.addDescription') }}</div>
<div>{{ $t('domains.description') }}</div>
<br/>
@@ -168,4 +170,4 @@ onMounted(async () => {
<SyncDns />
<DashboardDomain ref="dashboardDomainComponent"/>
</div>
</template>
</template>
+20 -18
View File
@@ -75,7 +75,7 @@ async function onAskIncomingToggle(value) {
}
const yes = await inputDialog.value.confirm({
title: t('email.disableEmailDialog.title', { domain: domain.value }),
title: t('email.disableEmailDialog.title'),
message: t('email.disableEmailDialog.description', { domain: domain.value }),
confirmStyle: 'danger',
confirmLabel: t('email.disableEmailDialog.disableAction'),
@@ -112,20 +112,20 @@ async function onEnableIncoming() {
}
const mailFromValidation = ref(false);
const mailFromValidationBusy = ref(false);
const customFrom = ref(false);
const customFromBusy = ref(false);
async function onToggleMailFromValidation(value) {
mailFromValidationBusy.value = true;
async function onToggleCustomFrom(value) {
customFromBusy.value = true;
const [error] = await mailModel.setMailFromValidation(domain.value, value);
const [error] = await mailModel.setMailFromValidation(domain.value, !value); // note: inverted logic between UI switch and API
if (error) {
mailFromValidation.value = !value;
mailFromValidationBusy.value = false;
customFrom.value = !value; // revert back old value
customFromBusy.value = false;
return console.error(error);
}
mailFromValidationBusy.value = false;
customFromBusy.value = false;
}
@@ -163,7 +163,7 @@ async function onDomainChanged() {
mailConfig.value = result;
inboundEnabled.value = result.enabled;
outboundEnabled.value = result.relay?.provider !== 'noop';
mailFromValidation.value = result.mailFromValidation;
customFrom.value = !result.mailFromValidation; // note: inverted logic between UI switch and API
signatureText.value = mailConfig.value.banner.text || '';
signatureHtml.value = mailConfig.value.banner.html || '';
@@ -227,7 +227,7 @@ onMounted(async () => {
</Dialog>
<Dialog ref="enableIncomingDialog"
:title="$t('email.enableEmailDialog.title', { domain: domain })"
:title="$t('email.enableEmailDialog.title')"
:confirm-label="$t('email.enableEmailDialog.enableAction')"
:confirm-busy="enableIncomeBusy"
:reject-label="$t('main.dialog.cancel')"
@@ -237,8 +237,7 @@ onMounted(async () => {
@reject="inboundEnabled = false"
>
<div>
<p v-html="$t('email.enableEmailDialog.description', { domain: domain, requiredPortsDocsLink: 'https://docs.cloudron.io/email/#required-ports' })"></p>
<p class="text-danger" v-if="adminDomainProvider === 'cloudflare'" v-html="$t('email.enableEmailDialog.cloudflareInfo', { adminDomain, mailFqdn })"></p>
<p v-html="$t('email.enableEmailDialog.description', { domain: domain, requiredPortsDocsLink: 'https://docs.cloudron.io/email/#firewall' })"></p>
<div v-if="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('email.enableEmailDialog.noProviderInfo')"></div>
<div v-else>
<Checkbox v-model="enableIncomingSetupDns" :disabled="enableIncomeBusy" :label="$t('email.enableEmailDialog.setupDnsCheckbox')"/>
@@ -256,6 +255,8 @@ onMounted(async () => {
reject-style="secondary"
@confirm="onSubmitSignature()"
>
<p v-html="$t('email.signature.description')"></p>
<div>
<form @submit.prevent="onSubmitSignature()" autocomplete="off">
<fieldset :disabled="signatureBusy">
@@ -285,20 +286,21 @@ onMounted(async () => {
<Button @click="onSendTestMail">{{ $t('emails.domains.testEmailTooltip') }}</Button>
</template>
<MailRelaySettingsItem v-if="domain" :domain="domain"/>
<MailRelaySettingsItem v-if="domain" :domain="domain" :adminDomain="adminDomain"/>
<SettingsItem>
<FormGroup>
<label>{{ $t('email.masquerading.title') }}</label>
<div v-html="$t('email.masquerading.description')"></div>
<label>{{ $t('email.customFrom.title') }}</label>
<div v-html="$t('email.customFrom.description')"></div>
</FormGroup>
<Switch v-model="mailFromValidation" @change="onToggleMailFromValidation" :disabled="mailFromValidationBusy"/>
<Switch v-model="customFrom" @change="onToggleCustomFrom" :disabled="customFromBusy"/>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('email.signature.title') }}</label>
<div v-html="$t('email.signature.description')"></div>
<div v-if="signatureHtml || signatureText">{{ $t('email.signature.customSignatureSet') }}</div>
<div v-else>{{ $t('email.signature.noSignatureSet') }}</div>
</FormGroup>
<div style="display: flex; align-items: center">
<Button tool plain @click="onShowSignatureDialog()">{{ $t('main.dialog.edit') }}</Button>
+3 -2
View File
@@ -13,6 +13,7 @@ const domainsModel = DomainsModel.create();
const mailModel = MailModel.create();
const domains = ref([]);
const busy = ref(true);
const searchFilter = ref('');
@@ -131,10 +132,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
@@ -119,7 +119,7 @@ onMounted(async () => {
</td>
</tr>
<tr v-if="eventlog.isOpen">
<td colspan="6" class="eventlog-details">
<td colspan="5" class="eventlog-details">
<pre>{{ JSON.stringify(eventlog, null, 4) }}</pre>
</td>
</tr>
+10 -6
View File
@@ -39,10 +39,14 @@ const columns = {
},
usage: {
label: t('email.incoming.mailboxes.usage'),
sort: true,
sort: (a, b) => {
if (!a.diskSize) return -1;
if (!b.diskSize) return 1;
return a.diskSize - b.diskSize;
},
hideMobile: true,
},
quota: {
storageQuota: {
label: 'Quota',
sort: true,
hideMobile: true,
@@ -208,7 +212,7 @@ onMounted(async () => {
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<Dialog ref="removeDialog"
:title="$t('email.deleteMailboxDialog.title', { name: removeMailbox.name, domain: removeMailbox.domain })"
:title="$t('email.deleteMailboxDialog.title')"
:confirm-label="$t('email.deleteMailboxDialog.deleteAction')"
:confirm-busy="removeBusy"
:confirm-active="!removeBusy"
@@ -220,7 +224,7 @@ onMounted(async () => {
>
<div>
<div class="text-danger" v-if="removeError">{{ removeError }}</div>
<div v-html="$t('email.deleteMailboxDialog.description')"></div>
<div v-html="$t('email.deleteMailboxDialog.description', { name: removeMailbox.name, domain: removeMailbox.domain })"></div>
<br/>
<Checkbox v-model="removePurge" :label="$t('email.deleteMailboxDialog.purgeMailboxCheckbox')" />
</div>
@@ -230,7 +234,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"/>
@@ -243,7 +247,7 @@ onMounted(async () => {
<span v-if="mailbox.usage || mailbox.usage === 0">{{ prettyDecimalSize(mailbox.usage.diskSize) }}</span>
<span v-else>{{ $t('main.loadingPlaceholder') }} ...</span>
</template>
<template #quota="mailbox">
<template #storageQuota="mailbox">
<span v-if="mailbox.usage && mailbox.usage.quotaLimit">{{ prettyDecimalSize(mailbox.usage.quotaLimit) }}</span>
</template>
<template #actions="mailbox">
@@ -142,7 +142,7 @@ onMounted(async () => {
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<Dialog ref="removeDialog"
:title="$t('email.deleteMailinglistDialog.title', { name: removeMailinglist.name, domain: removeMailinglist.domain })"
:title="$t('email.deleteMailinglistDialog.title')"
:confirm-label="$t('email.deleteMailinglistDialog.deleteAction')"
:confirm-busy="removeBusy"
:confirm-active="!removeBusy"
+3 -3
View File
@@ -216,7 +216,7 @@ onMounted(async () => {
<FormGroup>
<label for="dnsblZonesInput">{{ $t('emails.aclDialog.dnsblZones') }} <sup><a href="https://docs.cloudron.io/email/#dnsbl" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div>{{ $t('emails.aclDialog.dnsblZonesInfo') }}</div>
<div description>{{ $t('emails.aclDialog.dnsblZonesInfo') }}</div>
<textarea id="dnsblZonesInput" v-model="dnsblZonesString" :placeholder="$t('emails.aclDialog.dnsblZonesPlaceholder')" rows="4"></textarea>
<div class="error-label" v-if="dnsblZonesError">{{ dnsblZonesError }}</div>
</FormGroup>
@@ -275,9 +275,9 @@ onMounted(async () => {
<label for="maxEmailSizeInput">{{ $t('emails.settings.maxMailSize') }}</label>
<div v-html="$t('emails.changeMailSizeDialog.description')"></div>
</FormGroup>
<div style="display: flex; gap: 6px; align-items: center; flex-grow: 1;">
<div style="display: flex; gap: 6px; align-items: center; flex-grow: 1; justify-content: end">
{{ prettyDecimalSize(maxEmailSize) }}
<input style="flex-grow: 1" type="range" id="maxEmailSizeInput" v-model="maxEmailSize" step="1000000" min="1000000" max="1000000000" :disabled="maxEmailSizeBusy" />
<input style="flex-grow: 1; max-width: 300px" type="range" id="maxEmailSizeInput" v-model="maxEmailSize" step="1000000" min="1000000" max="1000000000" :disabled="maxEmailSizeBusy" />
<Button @click="onChangeMaxEmailSize()" tool :plain="currentMaxEmailSize !== maxEmailSize ? null : true" :loading="maxEmailSizeBusy" :disabled="maxEmailSizeBusy || currentMaxEmailSize === maxEmailSize">{{ $t('main.dialog.save') }}</Button>
</div>
</SettingsItem>
+4 -3
View File
@@ -110,11 +110,12 @@ function onEditOrAddGroup(group = null) {
async function onRemoveGroup(group) {
const yes = await inputDialog.value.confirm({
title: t('users.deleteGroupDialog.title', { name: group.name }),
message: t('users.deleteGroupDialog.description', { memberCount: group.userIds.length }),
title: t('users.deleteGroupDialog.title'),
message: t('users.deleteGroupDialog.description', { name: group.name, memberCount: group.userIds.length }),
confirmStyle: 'danger',
confirmLabel: t('users.deleteGroupDialog.deleteAction'),
rejectLabel: t('main.dialog.cancel')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
+11 -7
View File
@@ -121,11 +121,12 @@ async function onSubmit() {
async function onRemove(client) {
const yes = await inputDialog.value.confirm({
title: t('oidc.deleteClientDialog.title', { client: client.name }),
message: t('oidc.deleteClientDialog.description'),
title: t('oidc.deleteClientDialog.title'),
message: t('oidc.deleteClientDialog.description', { clientName: client.name }),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.delete'),
rejectLabel: t('main.dialog.cancel')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -157,11 +158,13 @@ onMounted(async () => {
<InputDialog ref="inputDialog" />
<Dialog ref="newSetDialog"
:title="$t('oidc.clientCredentials.title')"
:reject-label="$t('main.dialog.close')"
reject-style="secondary"
>
<div>
<!-- TODO translate -->
<div>New client credentials for <b>{{ clientName }}</b></div>
<div>{{ $t('oidc.clientCredentials.description', { clientName: clientName }) }}</div>
<br/>
<FormGroup>
<label for="clientIdInput">{{ $t('oidc.client.id') }}</label>
<InputGroup>
@@ -181,11 +184,11 @@ onMounted(async () => {
</Dialog>
<Dialog ref="editDialog"
:title="clientId ? $t('oidc.editClientDialog.title', { client: clientName }) : $t('oidc.newClientDialog.title')"
:title="clientId ? $t('oidc.editClientDialog.title') : $t('oidc.newClientDialog.title')"
:confirm-active="isValid"
:confirm-busy="submitBusy"
:confirm-label="clientId ? $t('main.dialog.save') : $t('oidc.newClientDialog.createAction')"
:reject-label="$t('main.dialog.close')"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
>
@@ -218,6 +221,7 @@ onMounted(async () => {
<FormGroup>
<label for="clientLoginRedirectUriInput">{{ $t('oidc.client.loginRedirectUri') }}</label>
<TextInput id="clientLoginRedirectUriInput" v-model="clientLoginRedirectUri" required/>
<small class="helper-text">{{ $t('oidc.client.loginRedirectUriPlaceholder') }}</small>
</FormGroup>
<FormGroup>
+10 -5
View File
@@ -31,14 +31,18 @@ async function onPasswordReset() {
error.value = {};
try {
await fetcher.post(`${API_ORIGIN}/api/v1/auth/password_reset_request`, { identifier: passwordResetIdentifier.value });
const res = await fetcher.post(`${API_ORIGIN}/api/v1/auth/password_reset_request`, { identifier: passwordResetIdentifier.value });
if (res.status === 409) {
error.value.generic = res.body.message;
} else if (res.status === 202) {
mode.value = MODE.NEW_PASSWORD_DONE;
}
} catch (error) {
error.value.generic = error;
console.error('Failed to reset password.', error);
}
busy.value = 'false';
mode.value = MODE.RESET_PASSWORD_DONE;
busy.value = false;
}
async function onNewPassword() {
@@ -107,6 +111,8 @@ onMounted(async () => {
<div v-if="mode === MODE.RESET_PASSWORD">
<h2>{{ $t('passwordReset.title') }}</h2>
<p class="has-error" v-if="error.generic">{{ error.generic }}</p>
<form name="passwordResetForm" @submit.prevent="onPasswordReset()">
<input type="submit" style="display: none;"/>
@@ -123,8 +129,7 @@ onMounted(async () => {
</div>
<div v-if="mode === MODE.RESET_PASSWORD_DONE">
<h4 v-if="error.generic" class="has-error">{{ error.generic }}</h4>
<h2 v-else>{{ $t('passwordReset.emailSent.title') }}</h2>
<h2>{{ $t('passwordReset.emailSent.title') }}</h2>
<Button href="/">{{ $t('passwordReset.backToLoginAction') }}</Button>
</div>
+1 -1
View File
@@ -191,7 +191,7 @@ async function onSubmit() {
config.noHardlinks = !providerConfig.value.useHardlinks;
config.mountOptions = {};
if (provider.value === 'cifs' || provider.value === 'sshfs' || provider.value === 'nfs') {
if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs') {
config.mountOptions.host = providerConfig.value.mountOptionHost;
config.mountOptions.remoteDir = providerConfig.value.mountOptionRemoteDir;
+11 -6
View File
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { computed, reactive, onMounted, ref, useTemplateRef } from 'vue';
import { computed, reactive, onMounted, ref, useTemplateRef, nextTick } from 'vue';
import { Button, Menu, TableView, ProgressBar, FormGroup, Checkbox, Dialog } from '@cloudron/pankow';
import { prettyBinarySize } from '@cloudron/pankow/utils';
import { each } from 'async';
@@ -42,7 +42,8 @@ const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(service, event) {
actionMenuModel.value = [{
icon: 'fa-solid fa-pencil-alt',
label: t('main.action.edit'),
label: t('main.action.configure'),
disabled() { return refreshBusy.value; },
visible: service.status !== 'disabled' && service.memoryLimit,
action: onEdit.bind(null, service),
}, {
@@ -93,6 +94,7 @@ async function refresh(id) {
services[id].memoryLimit = result.config.memoryLimit || 0;
services[id].memoryUsed = result.memoryUsed || 0;
services[id].memoryPercent = result.memoryPercent || 0;
services[id].defaultMemoryLimit = result.defaultMemoryLimit;
// we will poll until active
if (result.status !== 'active') setTimeout(refresh.bind(null, id), 3000);
@@ -160,6 +162,9 @@ async function onEdit(service) {
for (let i = startTick * 2; i < nearest256m; i *= 2) editMemoryTicks.value.push(i);
editMemoryTicks.value.push(nearest256m); // end tick
// unclear why this is needed for the slider value to update, but well
await nextTick();
dialog.value.open();
}
@@ -213,7 +218,7 @@ onMounted(async () => {
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<Dialog ref="dialog"
:title="$t('services.configure.title', { name: editService.name })"
:title="$t('services.configure.title')"
:confirm-busy="editBusy"
:confirm-label="$t('main.dialog.save')"
:reject-label="$t('main.dialog.cancel')"
@@ -222,6 +227,8 @@ onMounted(async () => {
>
<p class="has-error text-center" v-show="editError">{{ editError }}</p>
<p>{{ $t('services.configure.description', { name: editService.name }) }}</p>
<FormGroup>
<label for="memoryLimitInput" style="display: flex; justify-content: space-between;">
<span>{{ $t('services.memoryLimit') }}: {{ prettyBinarySize(editMemoryLimit) }}</span>
@@ -233,9 +240,7 @@ onMounted(async () => {
</datalist>
</FormGroup>
<br/>
<br/>
<Checkbox v-model="editRecoveryMode" :label="$t('services.configure.enableRecoveryMode')" />
<p v-html="$t('services.configure.recoveryModeDescription', { docsLink: 'https://docs.cloudron.io/troubleshooting/#unresponsive-service' })"></p>
<Checkbox v-model="editRecoveryMode" :label="$t('services.configure.enableRecoveryMode')" helpUrl="https://docs.cloudron.io/troubleshooting/#unresponsive-service"/>
</Dialog>
<Section :title="$t('services.title')">
+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();
@@ -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"/>
+4 -3
View File
@@ -179,11 +179,12 @@ function onInvitation(user) {
async function onRemoveUser(user) {
const yes = await inputDialog.value.confirm({
title: t('users.deleteUserDialog.title', { username: (user.username || user.email) }),
message: t('users.deleteUserDialog.description'),
title: t('users.deleteUserDialog.title'),
message: t('users.deleteUserDialog.description', { username: (user.username || user.email) }),
confirmStyle: 'danger',
confirmLabel: t('users.deleteUserDialog.deleteAction'),
rejectLabel: t('main.dialog.cancel')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
+62 -72
View File
@@ -73,47 +73,50 @@ const volumeDialogData = ref({
// dynamic extra props from openVolumeDialog
});
const volumeDialogValid = computed(() => {
const data = volumeDialogData.value;
const form = useTemplateRef('form');
const volumeDialogIsValid = ref(false);
function volumeDialogValid() {
function checkValidity() {
if (form.value && !form.value.checkValidity()) return false;
if (data.mode === 'new') {
if (!data.name) return false;
if (!data.mountType) return false;
const data = volumeDialogData.value;
switch (data.mountType) {
case 'filesystem':
case 'mountpoint':
if (!data.hostPath) return false;
if (!data.hostPath) return false;
break;
case 'ext4':
case 'xfs':
if (!data.diskPath) return false;
break;
case 'nfs':
if (!data.host) return false;
if (!data.remoteDir) return false;
break;
case 'sshfs':
if (!data.host) return false;
if (!data.remoteDir) return false;
if (!data.port) return false;
if (!data.user) return false;
if (!data.privateKey) return false;
break;
case 'cifs':
if (!data.host) return false;
if (!data.remoteDir) return false;
if (!data.username) return false;
if (!data.password) return false;
break;
default:
return false;
}
return true;
}
switch (data.mountType) {
case 'filesystem':
case 'mountpoint':
if (!data.hostPath) return false;
if (!data.hostPath) return false;
break;
case 'ext4':
case 'xfs':
if (!data.diskPath) return false;
break;
case 'nfs':
if (!data.host) return false;
if (!data.remoteDir) return false;
break;
case 'sshfs':
if (!data.host) return false;
if (!data.remoteDir) return false;
if (!data.port) return false;
if (!data.user) return false;
if (!data.privateKey) return false;
break;
case 'cifs':
if (!data.host) return false;
if (!data.remoteDir) return false;
if (!data.username) return false;
if (!data.password) return false;
break;
default:
return false;
}
return true;
});
volumeDialogIsValid.value = checkValidity();
}
async function refresh() {
busy.value = true;
@@ -141,11 +144,6 @@ async function refresh() {
const volumeDialog = useTemplateRef('volumeDialog');
const inputDialog = useTemplateRef('inputDialog');
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value.checkValidity();
}
async function openVolumeDialog(volume) {
volumeDialogData.value.error = null;
@@ -179,8 +177,6 @@ async function openVolumeDialog(volume) {
volumeDialogData.value.xfsBlockDevices = xfsBlockDevices;
volumeDialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
async function onSubmit() {
@@ -223,10 +219,12 @@ async function onSubmit() {
async function onRemove(volume) {
const yes = await inputDialog.value.confirm({
message: `Really remove volume ${volume.name}?`,
title: t('volumes.removeVolumeDialog.title'),
message: t('volumes.removeVolumeDialog.description', { volumeName: volume.name }),
confirmStyle: 'danger',
confirmLabel: t('volumes.removeVolumeDialog.removeAction'),
rejectLabel: t('main.dialog.cancel')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -273,25 +271,26 @@ onMounted(async () =>{
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<!-- width is to fix the 70 characters per line for ssh key -->
<Dialog ref="volumeDialog"
class="volume-dialog"
:title="volumeDialogData.mode === 'edit' ? $t('volumes.editVolumeDialog.title', { name: volumeDialogData.name }) : $t('volumes.addVolumeDialog.title')"
style="width: 78ch;"
:title="volumeDialogData.mode === 'edit' ? $t('volumes.editVolumeDialog.title') : $t('volumes.addVolumeDialog.title')"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-active="!volumeDialogData.busy && isFormValid"
:confirm-label="volumeDialogData.mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
:confirm-active="!volumeDialogData.busy && volumeDialogIsValid"
:confirm-busy="volumeDialogData.busy"
@confirm="onSubmit()"
>
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="volumeDialogValid()">
<fieldset :disabled="volumeDialogData.busy">
<input style="display: none;" type="submit" :disabled="!volumeDialogValid" />
<input style="display: none;" type="submit" :disabled="!volumeDialogIsValid" />
<div class="error-label" v-show="volumeDialogData.error">{{ volumeDialogData.error }}</div>
<FormGroup v-if="volumeDialogData.mode === 'new'">
<FormGroup>
<label for="volumeName">{{ $t('volumes.name') }}</label>
<TextInput id="volumeName" v-model="volumeDialogData.name" />
<TextInput id="volumeName" v-model="volumeDialogData.name" :readonly="volumeDialogData.mode === 'edit' ? true : undefined" required/>
</FormGroup>
<FormGroup>
@@ -301,7 +300,7 @@ onMounted(async () =>{
<FormGroup v-if="volumeDialogData.mountType === 'filesystem' || volumeDialogData.mountType === 'mountpoint'">
<label for="volumeHostPath">{{ $t('volumes.localDirectory') }}</label>
<TextInput id="volumeHostPath" v-model="volumeDialogData.hostPath" :placeholder="volumeDialogData.mountType === 'filesystem' ? '/srv/shared' : '/mnt/data'" />
<TextInput id="volumeHostPath" v-model="volumeDialogData.hostPath" :placeholder="volumeDialogData.mountType === 'filesystem' ? '/srv/shared' : '/mnt/data'" required/>
</FormGroup>
<FormGroup v-if="volumeDialogData.mountType === 'ext4' || volumeDialogData.mountType === 'xfs'">
@@ -312,7 +311,7 @@ onMounted(async () =>{
<FormGroup v-if="volumeDialogData.mountType === 'cifs' || volumeDialogData.mountType === 'nfs' || volumeDialogData.mountType === 'sshfs'">
<label for="volumeHost">{{ $t('volumes.addVolumeDialog.server') }}</label>
<TextInput v-model="volumeDialogData.host" id="volumeHost"/>
<TextInput v-model="volumeDialogData.host" id="volumeHost" required/>
</FormGroup>
<Checkbox v-if="volumeDialogData.mountType === 'cifs'" v-model="volumeDialogData.seal" :label="$t('backups.configureBackupStorage.cifsSealSupport')" />
@@ -324,28 +323,27 @@ onMounted(async () =>{
<FormGroup v-if="volumeDialogData.mountType === 'cifs' || volumeDialogData.mountType === 'nfs' || volumeDialogData.mountType === 'sshfs'">
<label for="volumeRemoteDir">{{ $t('volumes.addVolumeDialog.remoteDirectory') }}</label>
<TextInput v-model="volumeDialogData.remoteDir" id="volumeRemoteDir" placeholder="/share" />
<TextInput v-model="volumeDialogData.remoteDir" id="volumeRemoteDir" placeholder="/share"/>
</FormGroup>
<FormGroup v-if="volumeDialogData.mountType === 'cifs'">
<label for="volumeUsername">{{ $t('volumes.addVolumeDialog.username') }}</label>
<TextInput v-model="volumeDialogData.username" id="volumeUsername" />
<TextInput v-model="volumeDialogData.username" id="volumeUsername" required/>
</FormGroup>
<FormGroup v-if="volumeDialogData.mountType === 'cifs'">
<label for="volumePassword">{{ $t('volumes.addVolumeDialog.password') }}</label>
<MaskedInput v-model="volumeDialogData.password" id="volumePassword" />
<MaskedInput v-model="volumeDialogData.password" id="volumePassword" required/>
</FormGroup>
<FormGroup v-if="volumeDialogData.mountType === 'sshfs'">
<label for="volumeUser">{{ $t('volumes.addVolumeDialog.user') }}</label>
<TextInput v-model="volumeDialogData.user" id="volumeAddUser" />
<TextInput v-model="volumeDialogData.user" id="volumeAddUser" required/>
</FormGroup>
<FormGroup v-if="volumeDialogData.mountType === 'sshfs'">
<label for="volumePrivateKey">{{ $t('volumes.addVolumeDialog.privateKey') }}</label>
<!-- private key has 7 lines of 70 chars -->
<MaskedInput multiline rows="7" cols="72" v-model="volumeDialogData.privateKey" id="volumePrivateKey" />
<MaskedInput multiline rows="7" style="white-space: nowrap;" v-model="volumeDialogData.privateKey" id="volumePrivateKey" required/>
</FormGroup>
</fieldset>
@@ -354,7 +352,7 @@ onMounted(async () =>{
<Section :title="$t('volumes.title')">
<template #header-buttons>
<Button @click="openVolumeDialog()" icon="fa fa-plus">{{ $t('main.action.add') }}</Button>
<Button @click="openVolumeDialog()">{{ $t('main.action.add') }}</Button>
</template>
<div v-html="$t('volumes.description')"></div>
@@ -377,11 +375,3 @@ onMounted(async () =>{
</Section>
</div>
</template>
<style>
div.volume-dialog {
width: 78ch;
}
</style>
@@ -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' ]);
}

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